//! The direct-commit core: a check-in built straight from its parent's
//! manifest plus a list of edits, with no working tree and no temporary
//! checkout. `commit --stdin` calls it; a resident caller (a daemon, a C ABI)
//! keeps a `Context` open and calls `commit` per request.
//!
//! The manifest it mints is the one the working-tree commit
//! (`cmd/checkout.zig`, `runCommitInner`) would mint for the same change:
//! same F-set composition (`compose.zig`), same tag cards, same P/D/U/C cards,
//! same delta dialect and sub-manifest construction, same skip-delta base.
//! The differential test in `checkin_e2e.zig` holds the two byte for byte.
//!
//! What makes it cheap is what it does NOT read: a sub-manifest parent is
//! edited through `submanifest_editor.TreeEditor`, and the repository view is
//! opened once per `Context`, not once per stage. Touching only the nodes on
//! the edited paths' routes holds for a RESIDENT caller, whose context
//! recorded the parent: its editor holds a canonical basis for that root and
//! its rename memo knows which carried cards still name an old path. A
//! one-shot caller pays a whole-tree read twice over: `scanRenames` loads
//! every node to find those cards, and the cold editor streams the whole tree
//! to repartition it (about 90 ms of a 42k-file tree, both together).
//!
//! It prints nothing and knows no exit codes: every refusal is a typed error
//! with its human-readable detail in a `Diag` the caller renders.
const std = @import("std");
const artifact = @import("../artifact.zig");
const logstore = @import("../logstore.zig");
const storage = @import("../storage.zig");
const repostate = @import("../repostate.zig");
const repository = @import("../repository.zig");
const Repository = repository.Repository;
const editor = @import("../submanifest_editor.zig");
const LogTreeStore = @import("../log_tree_store.zig").LogTreeStore;
const store_open = @import("store_open.zig");
const storelock = @import("../storelock.zig");
const compose = @import("compose.zig");
const lookslike = @import("../lookslike.zig");
const ignore = @import("../ignore.zig");
const repocache = @import("../repocache.zig");
const control_paths = @import("../control_paths.zig");
const managed = @import("../managed.zig");
const digest_len = 32;
const sym_prefix = artifact.sym_tag_prefix;
/// The largest content one edit may carry: the storage frame ceiling.
pub const max_file_bytes: usize = managed.max_tracked_file_bytes;
/// The decoded-node budget of the context's tree editor. Enough to hold every
/// interior node and a working set of leaves of a very wide tree.
const tree_cache_bytes: usize = 64 << 20;
/// One change to the parent's file set.
pub const Edit = union(enum) {
/// PATH's content becomes `bytes`. `mode` null keeps the parent's mode
/// (regular for a new path).
put: Put,
/// PATH leaves the check-in.
delete: []const u8,
/// OLD becomes NEW, recorded as a rename (§4.7.6 old-name). `bytes` null
/// keeps OLD's content; `mode` null keeps OLD's mode. A put of NEW in
/// the same request, when this is the only rename onto NEW and it has no
/// `bytes`, gives it that put's bytes and mode.
rename: Rename,
pub const Put = struct {
path: []const u8,
bytes: []const u8,
mode: ?artifact.FileMode = null,
};
pub const Rename = struct {
old: []const u8,
new: []const u8,
bytes: ?[]const u8 = null,
mode: ?artifact.FileMode = null,
};
};
/// The commit time: whole seconds since the epoch plus milliseconds, the D
/// card's writer form.
pub const Time = struct {
epoch: i64,
ms: u16 = 0,
};
/// The content checks and the R card, as the caller resolved them from the
/// repository's settings (`binary-glob`, `crlf-glob`, `encoding-glob`,
/// `repo-cksum`). Sub-manifest emission is not here: it is the repository's
/// own config, read under the lock by `commit` itself.
pub const Policy = struct {
binary_glob: []const u8 = "",
crlf_glob: []const u8 = "",
encoding_glob: []const u8 = "",
repo_cksum: bool = false,
};
/// Caller-supplied steps that need what the core does not own: a process to
/// run, a signing key. Each runs after the manifest is composed and before
/// anything is written, so a refusal leaves the store as it was.
pub const Hooks = struct {
ctx: ?*anyopaque = null,
/// The project's before-commit check. Refusal has been reported by the
/// hook itself.
before_commit: ?*const fn (ctx: ?*anyopaque) error{Refused}!void = null,
/// Wraps the plain manifest (a clearsign). Null from the hook means "do
/// not sign"; returned bytes are owned by `gpa`. The wrapped text must
/// strip back to the plain manifest exactly, or the commit is refused.
seal: ?*const fn (ctx: ?*anyopaque, gpa: std.mem.Allocator, plain: []const u8) error{ Refused, OutOfMemory }!?[]u8 = null,
};
pub const Request = struct {
/// The check-in to build on, any name `resolve` accepts. Null: the newest
/// open leaf of `branch` when that branch is open, else of the main
/// branch; an empty repository records its first check-in.
parent: ?[]const u8 = null,
/// A merge, in place of `parent`: the first is the primary parent, which
/// the edits apply to and the fork guard checks, and the rest are merge
/// parents, recorded after it on the P card in the order given, as
/// `merge` then `commit` records them. The result tree is the primary's
/// plus the edits; with merge parents the edits may be empty. Each names
/// a check-in, as `parent` does, and no check-in twice.
parents: ?[]const []const u8 = null,
/// Land on this branch. Equal to the parent's branch, it changes nothing;
/// otherwise the check-in starts it, under commit's rules for a new branch.
branch: ?[]const u8 = null,
user: []const u8,
comment: []const u8,
now: Time,
edits: []const Edit,
/// Build on a parent that is no longer its branch's leaf.
allow_fork: bool = false,
/// Waive the content checks. The before-commit hook is the caller's and
/// reads its own copy of this choice.
no_checks: bool = false,
policy: Policy = .{},
hooks: Hooks = .{},
/// Filled when set, on every outcome: what the commit was built on.
report: ?*Report = null,
/// Answer a resend by its identity rather than its bytes: before the
/// parent is chosen, a stored check-in with this request's time (to the
/// millisecond), user and comment, on `branch` when one is named and
/// built on `parent` when one is named, is the answer, as `existing`.
/// A client that sends `parent` null cannot otherwise resend: the first
/// send moved the tip its edits are applied to. Off for the CLI, whose
/// clock makes every commit a new one.
find_resend: bool = false,
};
/// What a caller answering for a commit says beyond its name, which only the
/// core knows once it has chosen the parent.
pub const Report = struct {
/// Where `branch` is copied. The caller's.
arena: std.mem.Allocator,
/// The check-in the commit was built on; empty for the first check-in.
parent: Name = .{},
/// The branch the check-in is on (or, unchanged, its parent is on).
branch: []const u8 = "",
/// The parent has another child on that branch besides this check-in,
/// so the branch forks here; answered for a resend (`existing`) as for
/// the first send. Never set for a new branch or an unchanged commit.
forked: bool = false,
};
/// An artifact name: 64 hex for SHA3-256, 40 for a SHA-1 parent.
pub const Name = struct {
buf: [64]u8 = undefined,
len: u8 = 0,
pub fn of(hex: []const u8) Name {
std.debug.assert(hex.len == 40 or hex.len == 64);
var n: Name = .{ .len = @intCast(hex.len) };
@memcpy(n.buf[0..hex.len], hex);
return n;
}
pub fn slice(self: *const Name) []const u8 {
return self.buf[0..self.len];
}
};
pub const Outcome = union(enum) {
/// A new check-in was published.
recorded: Name,
/// Every edit matched the parent: nothing was written; the parent's name.
unchanged: Name,
/// The composed check-in is already in the store, byte for byte: nothing
/// was written; its name.
existing: Name,
};
/// Human-readable detail for the error a call returned: one or more lines,
/// LF-separated, no trailing LF, each a sentence the caller may prefix with
/// its own name. Empty when there is nothing to add (a lock wait that already
/// spoke on stderr, an allocation failure). Bounded: a longer message is cut.
pub const Diag = struct {
buf: [diag_max]u8 = undefined,
len: usize = 0,
pub const diag_max = 2048;
pub fn text(self: *const Diag) []const u8 {
return self.buf[0..self.len];
}
pub fn clear(self: *Diag) void {
self.len = 0;
}
pub fn set(self: *Diag, comptime fmt: []const u8, args: anytype) void {
var w: std.Io.Writer = .fixed(&self.buf);
w.print(fmt, args) catch {};
self.len = w.end;
}
};
pub const Error = error{
OutOfMemory,
/// The writer lock was held past the wait; the lock already said so.
StoreBusy,
/// An I/O failure reading or writing the store.
Storage,
/// The store, or the parent check-in, does not decode.
Corrupt,
/// The store is not one this build can open (a pending import, an
/// unreadable view).
CannotOpen,
/// No store at the directory given. Nothing in the detail: the caller
/// knows how it named the directory and says so.
NotARepo,
/// A request that cannot be acted on as given: an empty edit list, a path
/// a check-in cannot hold, two edits of one path, an empty branch name.
InvalidRequest,
/// A user or branch holding a control byte, or a path with characters
/// `control_paths.validateNewPath` refuses: a name the manifest would
/// carry, refused before it is minted into a check-in that can never be
/// changed. Spaces are fine; the card escapes carry them.
BadName,
/// `parent`, or one of `parents`, names nothing, or several things, or
/// not a check-in.
ParentNotFound,
/// No parent was named and the branch has no open leaf.
NoOpenLeaf,
/// `branch` on the first check-in, which starts the main branch.
BranchOnRoot,
/// `branch` names a branch that already has an open leaf.
BranchExists,
/// The parent is no longer its branch's leaf, and `allow_fork` is off.
NotTip,
/// An edit does not fit the parent's tree: a path under a file, a path
/// that is a directory, a delete or rename of an absent path, a rename
/// onto a present one, a symbolic link.
PathConflict,
/// Content larger than one artifact can hold.
TooLarge,
/// Content of a class the repository has not accepted.
ContentRefused,
/// A sub-manifest the parent names is not in the store.
MissingNode,
/// A content path the store's cone elides: the parent content is not here.
ElidedByCone,
HookRefused,
SignFailed,
};
/// The parent's symbolic tags a branch-root check-in must cancel (repo-state
/// §6.2). §6.2: "Branch creation MUST also cancel every pre-existing active
/// sym-* tag on the branch-root check-in ... because symbolic tags are
/// otherwise INHERITED from a check-in's parent." Inheritance is propagation,
/// so the cancel set is exactly the parent's PROPAGATING (`*`) syms: a singleton
/// (`+`) sym never propagates, so the child does not inherit it and cancelling
/// it would emit a spurious non-canonical `-sym-X` for a tag the child never
/// held. `zit tag add` mints exactly such a singleton sym (REGRESSION B6), so
/// this exclusion is reachable locally now, not only via a synced
/// `T +sym-X *`. The sym of `branch`, the branch being established, is
/// excluded, since `composeCommitTags` re-adds it as a propagating tag. Pure;
/// each surfaced name is duped onto `arena` so it outlives the read view.
/// `entries` come from `RepoState.tagsOf`, which surfaces active rows only (no
/// cancels).
pub fn selectInheritedSyms(
arena: std.mem.Allocator,
entries: []const repostate.RepoState.TagEntry,
branch: []const u8,
) error{OutOfMemory}![]const []const u8 {
const want_sym = try std.mem.concat(arena, u8, &.{ sym_prefix, branch });
var syms: std.ArrayList([]const u8) = .empty;
errdefer syms.deinit(arena);
for (entries) |e| {
std.debug.assert(e.tagtype != .cancel); // tagsOf surfaces active rows only
if (e.tagtype != .propagate) continue; // §6.2: only inherited (propagating) syms
if (!std.mem.startsWith(u8, e.name, sym_prefix)) continue;
if (std.mem.eql(u8, e.name, want_sym)) continue; // re-established as a propagating tag
try syms.append(arena, try arena.dupe(u8, e.name));
}
return syms.toOwnedSlice(arena);
}
/// The content classes a commit will not record silently.
pub const ContentFault = enum {
binary,
/// UTF-16 content, fossil's "Unicode" warning. One `encoding-glob`
/// class with `invalid_utf8`, kept as two variants because the operator is
/// told which of the two their file is.
unicode,
invalid_utf8,
crlf,
pub fn describe(self: ContentFault) []const u8 {
return switch (self) {
.binary => "contains NUL bytes (it looks binary)",
.unicode => "is UTF-16 (Unicode)",
.invalid_utf8 => "is not valid UTF-8",
.crlf => "has CR/LF line endings",
};
}
pub fn glob(self: ContentFault) []const u8 {
return switch (self) {
.binary => "binary-glob",
.unicode, .invalid_utf8 => "encoding-glob",
.crlf => "crlf-glob",
};
}
};
/// The resolved content policy for one commit: which files may carry
/// which content class, and whether the operator waived the check entirely.
/// The globs resolve through the ordinary tier chain, so a tracked
/// `.zit-settings/binary-glob` applies to a fresh clone with no consent step —
/// which is the whole reason these are DECLARATIVE rather than a
/// `before-commit` script, since that one runs for nobody until `zit trust`.
pub const ContentPolicy = struct {
binary_glob: []const u8 = "",
crlf_glob: []const u8 = "",
/// Fossil's `encoding-glob`: the files whose encoding this project
/// has accepted. Covers both encoding classes, as it does in fossil.
encoding_glob: []const u8 = "",
waived: bool = false,
/// The fault this file trips, or null when nothing objects.
///
/// A class is checked ONLY when its glob is set. An unset glob is "this
/// project has no opinion", not "refuse everything": committing a PNG is
/// ordinary, and a check that fired by default would make every repository
/// that never asked for it pay a two-step dance. Declaring the glob is what
/// opts a project in, and it reads naturally — `binary-glob = *.png` says
/// PNGs are the binaries this project expects, so a stray `.o` is refused.
///
/// This departs from fossil, which warns on binary content by default and
/// uses its globs to SUPPRESS the warning. The departure is deliberate: a
/// warning that fires on every ordinary commit is one nobody reads, and a
/// refusal nobody asked for is worse. zit checks when told to, and then
/// actually refuses — which is what makes it a policy rather than noise.
///
/// The ORDER is fossil's branch order (checkin.c): binary, then Unicode
/// (with or without CR), then invalid UTF-8, then CR/LF. A binary file
/// usually also contains CR/LF, and naming it "binary" is the diagnostic
/// the operator can act on; and a UTF-16 file carrying CR is exempt only
/// when BOTH globs permit it, which falls out of checking encoding first.
///
/// Binary is not merely first, it is TERMINAL — fossil's binary branch
/// returns, so no later class is consulted for a blob with a NUL, whether
/// or not `binary-glob` is declared. Without that, declaring `encoding-glob`
/// alone would start refusing every PNG and zip in the tree under a glob
/// that cannot exempt them, since binary formats are rarely valid UTF-8.
/// Invalid UTF-8 is terminal the same way (fossil's `fHasInvalidUtf8`
/// branch returns once `encoding-glob` permits the file), so a Latin-1
/// file with CR/LF that `encoding-glob` names is never a CR/LF fault.
/// UTF-16 is NOT terminal: fossil exempts Unicode-with-CR only when BOTH
/// globs permit it, so the CR/LF row still runs for it.
pub fn fault(self: ContentPolicy, path: []const u8, content: []const u8) ?ContentFault {
if (self.waived) return null;
const look = lookslike.classify(content);
if (look.nul) return if (objects(self.binary_glob, path)) .binary else null;
if (look.utf16 and objects(self.encoding_glob, path)) return .unicode;
if (look.invalid_utf8) return if (objects(self.encoding_glob, path)) .invalid_utf8 else null;
if (look.cr and objects(self.crlf_glob, path)) return .crlf;
return null;
}
/// This project declared the class (the glob is set) and did not name this
/// file. An empty glob is silence, and `*` is how the check is turned off.
fn objects(list: []const u8, path: []const u8) bool {
return list.len != 0 and !ignore.listMatches(list, path);
}
/// What Fossil would have prompted about and zit lets through when no
/// glob is declared: said in the commit receipt, never a refusal.
pub fn undeclared(self: ContentPolicy, content: []const u8) ?ContentFault {
if (self.waived) return null;
const look = lookslike.classify(content);
// Same terminal binary branch as `fault`: a blob with a NUL is
// reported as binary or not at all, never under another class's words.
if (look.nul) return if (self.binary_glob.len == 0) .binary else null;
if (self.encoding_glob.len == 0 and look.utf16) return .unicode;
if (look.invalid_utf8) return if (self.encoding_glob.len == 0) .invalid_utf8 else null;
if (self.crlf_glob.len == 0 and look.cr) return .crlf;
return null;
}
};
/// File contents a commit has captured but not yet stored. Preparation
/// runs without the writer lock, so it cannot append; what the store lacks
/// is held here and appended by the publish step, under the lock.
pub const PendingBlobs = struct {
items: std.ArrayList(Item) = .empty,
index: std.AutoHashMapUnmanaged([digest_len]u8, usize) = .empty,
pub const Item = struct {
digest: [digest_len]u8,
bytes: []u8,
/// The same path's blob in the parent: a delta-base hint, valid for
/// as long as the store is only appended to.
base: ?logstore.Rid,
};
pub fn deinit(self: *PendingBlobs, gpa: std.mem.Allocator) void {
for (self.items.items) |it| gpa.free(it.bytes);
self.items.deinit(gpa);
self.index.deinit(gpa);
}
/// Takes ownership of `bytes`. Two paths with equal content keep one copy.
pub fn add(self: *PendingBlobs, gpa: std.mem.Allocator, digest: [digest_len]u8, bytes: []u8, base: ?logstore.Rid) error{OutOfMemory}!void {
const got = self.index.getOrPut(gpa, digest) catch |e| {
gpa.free(bytes);
return e;
};
if (got.found_existing) {
gpa.free(bytes);
return;
}
self.items.append(gpa, .{ .digest = digest, .bytes = bytes, .base = base }) catch |e| {
_ = self.index.remove(digest);
gpa.free(bytes);
return e;
};
got.value_ptr.* = self.items.items.len - 1;
}
pub fn get(self: *const PendingBlobs, digest: []const u8) ?[]const u8 {
// A commit captures under SHA3 only, so a SHA-1 name a parent carries
// forward is never one of these.
if (digest.len != digest_len) return null;
const i = self.index.get(digest[0..digest_len].*) orelse return null;
return self.items.items[i].bytes;
}
};
/// The most primary-parent hops `skipBaseOf` walks before giving up on the
/// skip base, so a cycle in a damaged store or an absurd history cannot make
/// a commit spin. Far past any real history.
const skip_walk_max: u64 = 1 << 24;
/// The skip-delta base of a check-in made on `parent` (`logstore.skipBaseHeight`):
/// with the parent at height h along primary parents (root = 0), the new
/// check-in sits at n = h + 1 and bases on its primary ancestor at height
/// n & (n - 1), which is 2^ctz(n) - 1 hops above the parent. A branch counts
/// its height back through its fork point, and a merge along its primary
/// line only. Null when the walk passes `skip_walk_max`; the caller then
/// falls back to the parent. The height walk is O(height) over the in-memory
/// edges, since nothing cheaper records a primary-line height; at 1.5M
/// check-ins that is tens of milliseconds per commit.
pub fn skipBaseOf(state: *const repostate.RepoState, parent: repostate.Rid) ?repostate.Rid {
var h: u64 = 0;
var at = parent;
while (state.primaryParentOf(at)) |pp| : (h += 1) {
if (h >= skip_walk_max) return null;
at = pp;
}
var hops = h - logstore.skipBaseHeight(h + 1);
at = parent;
while (hops > 0) : (hops -= 1) at = state.primaryParentOf(at) orelse unreachable;
return at;
}
/// Dialect memory read: has this repository ever stored a delta manifest?
/// Config-absent or unreadable reads as false — the flat default.
pub fn seenDeltaManifest(gpa: std.mem.Allocator, log: *logstore.LogStore) bool {
var oc = (log.readConfig(gpa) catch return false) orelse return false;
defer oc.deinit(gpa);
return oc.config.seen_delta_manifest;
}
/// Record the seen-delta indicator, on the false->true edge only — the same
/// plan-E last-frame-wins append `setSeenSubmanifest` uses, landing in the
/// same `log.commit()` as the delta manifest it is about.
pub fn setSeenDelta(gpa: std.mem.Allocator, log: *logstore.LogStore) logstore.PutError!void {
var oc = log.readConfig(gpa) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Storage,
};
var cfg: logstore.Config = if (oc) |c| c.config else .{ .hash_policy = 0, .default_branch = "", .project_id = "" };
defer if (oc) |*c| c.deinit(gpa);
if (cfg.seen_delta_manifest) return;
cfg.seen_delta_manifest = true;
try log.putConfig(cfg);
}
/// submanifests §8.1 — record the local seen-submanifest indicator, on the
/// false->true edge only. Appends a config frame carrying every existing field
/// plus the flag (plan E last-frame-wins); a repository already carrying it writes
/// nothing. The caller's `log.commit()` makes it durable with the artifacts it
/// describes, so the indicator and the trees it is about land in one fsync.
pub fn setSeenSubmanifest(gpa: std.mem.Allocator, log: *logstore.LogStore) logstore.PutError!void {
var oc = log.readConfig(gpa) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
// A config frame that will not decode is not a reason to lose a commit's
// artifacts; the §7.2 gate arms on the setting alone in that case, and
// `zit submanifest` shows the operator the unreadable config.
error.Storage, error.Corrupt => return,
};
defer if (oc) |*c| c.deinit(gpa);
var cfg: logstore.Config = if (oc) |c| c.config else .{ .hash_policy = 0, .default_branch = "", .project_id = "" };
if (cfg.seen_submanifest) return; // already recorded — no frame per commit
cfg.seen_submanifest = true;
try log.putConfig(cfg);
}
/// What a caller keeps open across commits: an append handle on the store's
/// log, the repository view, and the tree editor with its decoded-node cache.
/// The writer lock is taken by each `commit` and held from choosing the
/// parent until the check-in is published, so another writer can land
/// between two commits but never inside one; `commit` reads whatever it
/// appended before choosing a parent. A one-shot caller that does work of
/// its own under the lock (`commit --stdin` resolves settings in a scratch
/// directory beside the store) opens with `hold_lock` instead, and the lock
/// is held from `open` to `deinit`.
///
/// Pinned: `open` initializes in place, and the log and tree store point
/// into the value, so it must not be copied or moved once opened. The
/// directory passed to `open` is borrowed and must stay open with it.
pub const Context = struct {
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
file: std.Io.File,
fs: storage.FileStorage,
log: logstore.LogStore,
/// False only after a failed reopen in `begin`: the log is gone and every
/// commit refuses until the context is reopened.
log_open: bool,
/// The log as this context last saw it under the lock. Null when it could
/// not be read, which makes the next commit read the log again.
key: ?logstore.LogStore.LogKey,
lock: ?storelock.Lock,
/// The lock is held from `open` to `deinit` rather than per commit.
hold_lock: bool,
/// Between `beginBatch` and `endBatch`: the lock is held across several
/// commits, and their appends are made durable by one fsync at the end.
batching: bool,
repo: Repository,
/// The view predates an append: this context's own publish or another
/// writer's.
repo_stale: bool,
/// False only after a failed reopen of the view, as `log_open`.
repo_open: bool,
tree_store: LogTreeStore,
tree_editor: ?editor.TreeEditor,
/// The renamed cards (F cards carrying an old-name) of the check-in this
/// context last recorded, so the next commit on it need not search the
/// tree for them. Keyed by that check-in's name, so another writer's
/// appends cannot make it wrong, only unused.
renamed_memo: ?RenamedMemo,
/// Check-ins by D-card time, for `find_resend`. Built from the view on
/// first use, extended with each check-in this context records, and
/// dropped whenever another writer's append or a rewrite is noticed.
resend_index: ?StoredIndex,
/// Test seam: the next `endBatch` behaves as though its fsync failed.
fail_next_sync: bool,
const RenamedMemo = struct {
checkin: Name,
arena: std.heap.ArenaAllocator,
cards: []const artifact.FCard,
};
/// Open the store under `root_dir` (a store directory or a checkout that
/// names one): its log for append, repairing a torn tail a crashed writer
/// left, and the repository view. Takes the writer lock for the open and
/// releases it before returning, unless `options.hold_lock`.
pub fn open(self: *Context, gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, options: OpenOptions, diag: *Diag) Error!void {
diag.clear();
self.* = .{
.gpa = gpa,
.io = io,
.root_dir = root_dir,
.file = undefined,
.fs = undefined,
.log = undefined,
.log_open = false,
.key = null,
.lock = null,
.hold_lock = options.hold_lock,
.batching = false,
.repo = undefined,
.repo_stale = false,
.repo_open = false,
.tree_store = .{ .gpa = gpa, .log = &self.log },
.tree_editor = null,
.renamed_memo = null,
.resend_index = null,
.fail_next_sync = false,
};
const held = try self.lockStore(diag);
self.file = held.file;
defer if (!options.hold_lock) self.unlockStore();
errdefer if (options.hold_lock) self.unlockStore();
errdefer self.file.close(io);
try self.openLog(diag);
errdefer self.log.deinit(gpa);
try self.openView(diag);
}
pub const OpenOptions = struct {
hold_lock: bool = false,
};
pub fn deinit(self: *Context) void {
std.debug.assert(!self.batching);
std.debug.assert(self.hold_lock or self.lock == null);
self.unlockStore();
if (self.renamed_memo) |*m| m.arena.deinit();
self.dropResendIndex();
if (self.tree_editor) |*t| t.deinit();
self.tree_store.deinitCache();
if (self.repo_open) self.repo.deinit(self.gpa);
if (self.log_open) self.log.deinit(self.gpa);
self.file.close(self.io);
self.* = undefined;
}
/// Take the writer lock, and with it a fresh handle on the store: the
/// file at the store's path now, which a rewrite may have replaced since
/// this context last looked. The caller owns the returned file.
fn lockStore(self: *Context, diag: *Diag) Error!store_open.StoreAppend {
std.debug.assert(self.lock == null);
const held = store_open.openStoreAppend(self.io, self.root_dir) catch |e| {
if (e == error.StoreBusy) return error.StoreBusy;
return ioFailure(diag);
};
self.lock = held.lock;
return held;
}
/// The end of one commit: the lock goes unless the caller holds it or a
/// batch does.
fn end(self: *Context) void {
if (!self.hold_lock and !self.batching) self.unlockStore();
}
/// Take the lock for several commits, catching up with the store once.
/// Each `commit` until `endBatch` publishes without its own fsync, so a
/// caller that answers only after `endBatch` pays one sync for the lot.
/// Every commit stays independent: one refused leaves the others as they
/// would be alone, and each is idempotent on its own bytes. On error the
/// lock is not held and no batch is open.
pub fn beginBatch(self: *Context, diag: *Diag) Error!void {
std.debug.assert(!self.hold_lock and !self.batching);
diag.clear();
try self.begin(diag);
self.batching = true;
}
/// Make the batch's appends durable in one fsync and release the lock,
/// whether or not the sync succeeded. A failed sync means none of the
/// batch's check-ins may be reported as recorded, and the open log is
/// dropped: it still holds the unsynced frames, and answering a resend
/// from them would claim bytes the disk may not have. Every later commit
/// refuses until the context is opened again.
pub fn endBatch(self: *Context, diag: *Diag) Error!void {
std.debug.assert(self.batching and self.lock != null);
diag.clear();
defer {
self.batching = false;
self.unlockStore();
}
if (!self.log_open) return ioFailure(diag);
const failed = self.fail_next_sync or blk: {
self.log.commit() catch break :blk true;
break :blk false;
};
self.fail_next_sync = false;
if (!failed) return;
self.log.deinit(self.gpa);
self.log_open = false;
self.key = null;
self.dropResendIndex();
diag.set("cannot make the check-ins durable: the store's fsync failed", .{});
return error.Storage;
}
fn dropResendIndex(self: *Context) void {
if (self.resend_index) |*index| index.deinit(self.gpa);
self.resend_index = null;
}
fn unlockStore(self: *Context) void {
if (self.lock) |*l| l.release(self.io);
self.lock = null;
}
fn openLog(self: *Context, diag: *Diag) Error!void {
std.debug.assert(self.lock != null and !self.log_open);
const st = self.file.stat(self.io) catch return ioFailure(diag);
self.fs = storage.FileStorage.init(self.io, self.file, st.size);
// `hash_kind` 0 is a don't-care on a non-empty file: `open` reads the
// stored kind from the header.
self.log = logstore.LogStore.open(self.gpa, self.fs.storageFile(), 0) catch |e| return logOpenError(e, diag);
self.log_open = true;
self.key = self.log.logKey() catch null;
}
fn openView(self: *Context, diag: *Diag) Error!void {
std.debug.assert(!self.repo_open);
self.repo.open(self.gpa, self.io, self.root_dir, .{ .mode = .full }) catch |e| {
switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.CannotRead => diag.set("cannot read repository: input/output error", .{}),
error.Corrupt => diag.set("cannot read repository: repository is corrupt", .{}),
error.ImportPending => diag.set("an incremental import is pending; run 'zit import-git --recover DIR'", .{}),
error.NotARepo => return error.NotARepo,
}
return if (e == error.Corrupt) error.Corrupt else error.CannotOpen;
};
self.repo_open = true;
self.repo_stale = false;
}
/// Take the lock for one commit and catch up with the store. Unchanged
/// since this context last held the lock: only its own publish needs
/// folding into the view (the cache extension path, costing the tail).
/// Appended to by another writer, or replaced by a rewrite (`pack`,
/// `shun --force`, which installs a new file): the log is read again
/// from a fresh handle, and every cache keyed by log offsets is dropped.
fn begin(self: *Context, diag: *Diag) Error!void {
if (!self.log_open or !self.repo_open) {
diag.set("cannot read repository: input/output error", .{});
return error.CannotOpen;
}
// A held lock means no other writer has appended since `open` (or
// since the batch began): only this context's own publishes need
// folding into the view.
if (self.hold_lock or self.batching) return self.refreshView(diag);
var fresh = (try self.lockStore(diag)).file;
errdefer self.end();
var fresh_owned = true;
defer if (fresh_owned) fresh.close(self.io);
const st = fresh.stat(self.io) catch return ioFailure(diag);
var fresh_fs = storage.FileStorage.initReadOnly(self.io, fresh, st.size);
const unchanged = if (self.key) |k| logstore.LogStore.validateTail(fresh_fs.storageFile(), k) else false;
if (!unchanged) {
self.log.deinit(self.gpa);
self.log_open = false;
self.file.close(self.io);
self.file = fresh;
fresh_owned = false;
try self.openLog(diag);
self.tree_store.deinitCache();
self.tree_store.cache = .{ .memoize_undeltaed = true };
// A rewrite can drop a node (a shun); nothing decoded before it
// is trusted after it.
if (self.tree_editor) |*t| t.rebindStore(self.tree_store.treeStore());
self.repo_stale = true;
self.dropResendIndex();
}
try self.refreshView(diag);
}
fn refreshView(self: *Context, diag: *Diag) Error!void {
if (!self.repo_stale) return;
self.repo.deinit(self.gpa);
self.repo_open = false;
try self.openView(diag);
}
/// The tree editor for partition parameter `k`, made on first use and
/// remade when the repository's pinned k changes.
fn treeEditor(self: *Context, k: u4) Error!*editor.TreeEditor {
if (self.tree_editor) |*t| {
if (t.format.shape.tree.k == k) return t;
t.deinit();
self.tree_editor = null;
}
self.tree_editor = editor.TreeEditor.init(self.gpa, self.tree_store.treeStore(), .{
.hash_algo = .sha3_256,
.shape = .{ .tree = .{ .k = k, .height_max = @intCast(artifact.max_construct_height) } },
}, tree_cache_bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => unreachable, // k comes from `Emission.treeK`, in range by construction
};
return &self.tree_editor.?;
}
};
fn logOpenError(e: logstore.OpenError, diag: *Diag) Error {
switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.Storage => {
diag.set("cannot write the check-in: input/output error", .{});
return error.Storage;
},
error.Corrupt => {
diag.set("the repository is corrupt", .{});
return error.Corrupt;
},
}
}
fn ioFailure(diag: *Diag) Error {
diag.set("cannot write the check-in: input/output error", .{});
return error.Storage;
}
fn putError(e: anyerror, diag: *Diag) Error {
if (e == error.OutOfMemory) return error.OutOfMemory;
diag.set("cannot write the check-in: input/output error", .{});
return error.Storage;
}
fn parentCorrupt(diag: *Diag, why: []const u8) Error {
diag.set("the parent check-in is corrupt ({s})", .{why});
return error.Corrupt;
}
fn contentError(e: logstore.ContentError, diag: *Diag) FindError {
switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.Storage => {
diag.set("cannot write the check-in: input/output error", .{});
return error.Storage;
},
error.Corrupt => {
diag.set("the parent check-in is corrupt", .{});
return error.Corrupt;
},
}
}
/// What `parentChecks` decided.
pub const ParentChecked = struct {
/// The parent's inherited sym-* tags a `--branch` commit cancels, on `ma`.
syms: []const []const u8,
/// The name of the new manifest's skip-delta base (`skipBaseOf`), on `ma`.
skip_base: ?[]const u8 = null,
};
pub const ParentCheckArgs = struct {
/// The branch the check-in moves onto, when it leaves its parent's.
switch_branch: ?[]const u8,
allow_fork: bool,
};
/// The checks a commit makes against the rest of the repository, not just its
/// own tree: the fork guard, and for a branch switch, that NAME is not an open
/// branch and which of the parent's sym-* tags the new check-in cancels.
/// `branch` is the parent's branch; `parent` the check-in the P card names.
pub fn parentChecks(
repo: *Repository,
gpa: std.mem.Allocator,
ma: std.mem.Allocator,
args: ParentCheckArgs,
branch: []const u8,
parent: []const u8,
want_skip_base: bool,
diag: *Diag,
) error{ OutOfMemory, NotTip, BranchExists }!ParentChecked {
const facts = try parentFacts(repo, gpa, ma, args, branch, parent, want_skip_base, diag);
if (facts.refusal) |e| return e;
return facts.checked;
}
/// `parentChecks`' answer with its refusal held as data: the cards a
/// check-in on `parent` carries are known whether or not the fork guard or
/// the branch check would refuse it, so `commit` can first ask whether that
/// exact check-in is already stored (a resend), which neither check refuses.
const ParentFacts = struct {
checked: ParentChecked,
/// Set with its message in the diag.
refusal: ?error{ NotTip, BranchExists } = null,
};
fn parentFacts(
repo: *Repository,
gpa: std.mem.Allocator,
ma: std.mem.Allocator,
args: ParentCheckArgs,
branch: []const u8,
parent: []const u8,
want_skip_base: bool,
diag: *Diag,
) error{OutOfMemory}!ParentFacts {
var refusal: ?error{ NotTip, BranchExists } = null;
// Fork guard (fossil's commit-time warning): a commit whose
// parent already has a same-branch child would silently fork the
// branch. The condition is fossil's — the parent must still be a
// LEAF — so committing onto the older head of an already-forked
// branch stays legal (that head is a leaf too). A branch switch is
// exempt (the check-in leaves the branch, a fork by name), as is
// `allow_fork` (the deliberate case). No network: this also fires
// for offline divergence that no autosync could have prevented.
if (args.switch_branch == null and !args.allow_fork) {
if (repo.checkinRid(parent)) |prid| {
if (!repo.state().isLeaf(prid)) {
const tip = repo.resolve(branch, .checkin) catch null;
if (tip) |trid| {
diag.set(
"not the tip of branch '{s}' — check-in {s} is newer.\n" ++
"run 'zit update' to join it, or commit with --allow-fork to fork deliberately",
.{ branch, repo.hashOf(trid) },
);
} else {
diag.set(
"the current check-in is no longer the tip of branch '{s}'.\n" ++
"run 'zit update' to join the tip, or commit with --allow-fork to fork deliberately",
.{branch},
);
}
refusal = error.NotTip;
}
}
}
var skip_base: ?[]const u8 = null;
if (want_skip_base) if (repo.checkinRid(parent)) |prid| if (skipBaseOf(repo.state(), prid)) |brid| {
skip_base = try ma.dupe(u8, repo.hashOf(brid));
};
const bname = args.switch_branch orelse return .{ .checked = .{ .syms = &.{}, .skip_base = skip_base }, .refusal = refusal };
const open_leaves = try repo.state().openLeavesOf(gpa, bname);
defer gpa.free(open_leaves);
if (open_leaves.len != 0) {
diag.set("branch '{s}' already exists with an open leaf; commit onto it or choose a new name", .{bname});
refusal = error.BranchExists;
}
// The parent's inherited sym-* tags to cancel, minus the one we
// re-establish.
const prid = repo.checkinRid(parent) orelse return .{ .checked = .{ .syms = &.{}, .skip_base = skip_base }, .refusal = refusal };
const entries = try repo.state().tagsOf(gpa, prid);
defer gpa.free(entries);
const syms = try selectInheritedSyms(ma, entries, bname);
return .{ .checked = .{ .syms = syms, .skip_base = skip_base }, .refusal = refusal };
}
/// The parent a commit builds on, as the repository view names it.
const Parent = struct {
/// Null for the first check-in of an empty repository.
rid: ?repostate.Rid,
hash: []const u8,
/// The branch the parent is on (the main branch for the first check-in).
branch: []const u8,
/// The branch the check-in moves onto, when it leaves `branch`.
switch_branch: ?[]const u8,
};
/// The parent's file set as this commit reads it: a sorted F list, or the S
/// run of a sub-manifest parent edited in place by the tree editor.
const ParentView = union(enum) {
flat: []const artifact.FCard,
tree: []const artifact.SCard,
};
/// The parent manifest and what it was resolved from. Owns the parsed
/// manifests; cards in `view` borrow them.
const Base = struct {
bytes: ?[]u8 = null,
manifest: ?artifact.Manifest = null,
normalized: ?[]u8 = null,
baseline_bytes: ?[]u8 = null,
baseline: ?artifact.Manifest = null,
baseline_normalized: ?[]u8 = null,
rid: ?logstore.Rid = null,
view: ParentView = .{ .flat = &.{} },
fn deinit(self: *Base, gpa: std.mem.Allocator) void {
if (self.baseline) |*m| m.deinit();
if (self.baseline_normalized) |b| gpa.free(b);
if (self.baseline_bytes) |b| gpa.free(b);
if (self.manifest) |*m| m.deinit();
if (self.normalized) |b| gpa.free(b);
if (self.bytes) |b| gpa.free(b);
self.* = undefined;
}
};
/// One path's new state.
const Change = struct {
path: []const u8,
/// The new card, or null when the path leaves the check-in.
card: ?artifact.FCard,
/// Content this commit introduces: checked against the policy, stored
/// when the store lacks it. Null for a delete or unchanged content.
bytes: ?[]const u8 = null,
/// The same path's blob in the parent: a delta-base hint.
base: ?logstore.Rid = null,
};
/// Record a check-in: `req.edits` applied to the parent's file set. Holds the
/// context's lock throughout, so it publishes on the parent it chose.
pub fn commit(ctx: *Context, req_in: Request, diag: *Diag) Error!Outcome {
diag.clear();
try ctx.begin(diag);
defer ctx.end();
const gpa = ctx.gpa;
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
const ma = arena_state.allocator();
try checkRequest(req_in, diag);
// From here the primary parent is `parent`, whichever way it was named.
var req = req_in;
if (req_in.parents) |ps| req.parent = ps[0];
if (req.find_resend) if (try findResend(ctx, req, diag)) |held| return .{ .existing = held };
const parent = try resolveParent(ctx, ma, req, diag);
const merged = try resolveMerged(ctx, ma, req, parent.hash, diag);
if (parent.rid == null and parent.switch_branch != null) {
diag.set("--branch needs a parent check-in; the first commit starts branch '{s}'", .{parent.branch});
return error.BranchOnRoot;
}
if (req.report) |r| {
r.parent = if (parent.rid != null) .of(parent.hash) else .{};
r.branch = try r.arena.dupe(u8, parent.switch_branch orelse parent.branch);
r.forked = false;
}
// The repository's own emission settings, from the frame under the lock.
var config: ?logstore.OwnedConfig = ctx.log.readConfig(gpa) catch null;
defer if (config) |*c| c.deinit(gpa);
const emission: repository.submanifest.Emission = if (config) |c| repository.submanifest.emission(.of(c.config)) else .off;
const tree_k: ?u4 = emission.treeK();
const seen_delta = if (config) |c| c.config.seen_delta_manifest else false;
var base: Base = .{};
defer base.deinit(gpa);
try loadBase(ctx, &base, parent.hash, tree_k != null, diag);
const tree_editor: ?*editor.TreeEditor = if (tree_k) |k| try ctx.treeEditor(k) else null;
var pending: PendingBlobs = .{};
defer pending.deinit(gpa);
const changes = try collectChanges(ctx, ma, &base, req, diag);
// A merge is a change of its own: the parents it joins.
if (changes.len == 0 and merged.len == 0) {
std.debug.assert(parent.rid != null);
return .{ .unchanged = .of(parent.hash) };
}
var checked: ParentChecked = .{ .syms = &.{} };
// A refusal from the fork guard or the branch check stands unless the
// check-in this request makes is already stored: a resend of a commit
// that landed is answered with its name, not refused by its own child.
var refusal: ?error{ NotTip, BranchExists } = null;
var refusal_diag: Diag = .{};
if (parent.rid != null) {
const facts = try parentFacts(&ctx.repo, gpa, ma, .{
.switch_branch = parent.switch_branch,
.allow_fork = req.allow_fork,
}, parent.branch, parent.hash, true, &refusal_diag);
checked = facts.checked;
refusal = facts.refusal;
}
// Policy gates what this commit INTRODUCES, not the tree it inherits;
// checked before anything is stored, so a refused commit mints nothing.
const content_policy: ContentPolicy = .{
.binary_glob = req.policy.binary_glob,
.crlf_glob = req.policy.crlf_glob,
.encoding_glob = req.policy.encoding_glob,
.waived = req.no_checks,
};
for (changes) |c| {
const bytes = c.bytes orelse continue;
if (content_policy.fault(c.path, bytes)) |f| {
diag.set("refusing '{s}': it {s}\naccept it for good with 'zit settings {s} \"{s}\"' (a tracked .zit-settings/{s} applies to every clone), or once with --no-checks", .{ c.path, f.describe(), f.glob(), c.path, f.glob() });
return error.ContentRefused;
}
}
for (changes) |c| {
const bytes = c.bytes orelse continue;
var digest: [digest_len]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
if (ctx.log.lookup(&digest) != null) continue;
try pending.add(gpa, digest, try gpa.dupe(u8, bytes), c.base);
}
// The new file set: S cards from the tree editor, else an F run.
var staging = StagingStore.init(gpa, ctx.tree_store.treeStore());
defer staging.deinit();
var built: ?editor.BuildResult = null;
defer if (built) |*b| b.deinit();
var published = false;
defer if (tree_editor) |t| {
// Nodes decoded through the staging store name artifacts the log
// does not hold unless the commit published them.
if (published) t.rebindContentAddressedStore(ctx.tree_store.treeStore()) else t.rebindStore(ctx.tree_store.treeStore());
};
var flat_f: []const artifact.FCard = &.{};
var tree_s: []const artifact.SCard = &.{};
// A tree parent is edited in place only by a tree editor (`loadBase`).
std.debug.assert(base.view == .flat or tree_editor != null);
if (tree_editor) |t| {
t.rebindContentAddressedStore(staging.treeStore());
const commands = try treeCommands(ctx, ma, &base, parent.hash, changes, diag);
const root: editor.RootView = switch (base.view) {
.flat => |f| .{ .flat = try stripRenames(ma, f) },
.tree => |s| .{ .tree = s },
};
built = t.apply(root, commands) catch |e| return treeError(e, diag);
switch (built.?.root) {
.flat => |f| flat_f = f,
.tree => |s| tree_s = s,
}
} else {
flat_f = try composeFlat(ma, base.view.flat, changes);
}
// Dialect continuation: a flat check-in in a repository carrying delta
// manifests is itself a delta when that is smaller (`compose.deltaPatch`).
var delta: ?compose.DeltaPatch = null;
if (tree_s.len == 0 and base.manifest != null) {
if (compose.dialectBase(
&base.manifest.?,
if (base.baseline) |*b| b else null,
parent.hash,
base.manifest.?.b != null or seen_delta,
)) |dbase| delta = try compose.deltaPatch(ma, flat_f, dbase);
}
// submanifests §4.4: the file set is an F run or an S run, never both,
// and a delta (B card) never sits on a tree.
std.debug.assert(tree_s.len == 0 or (flat_f.len == 0 and delta == null));
var r_hex: [32]u8 = undefined;
const r_card: ?artifact.RCard = if (req.policy.repo_cksum)
try checksumCard(ctx, ma, &staging, &pending, flat_f, tree_s, &r_hex, diag)
else
null;
var ts_buf: [23]u8 = undefined;
const ts = artifact.formatWriterTimestampMs(req.now.epoch, req.now.ms, &ts_buf);
const tags = try artifact.composeCommitTags(ma, .{
.branch = if (parent.rid == null) parent.branch else parent.switch_branch,
.close = false,
.parent_syms = checked.syms,
});
const parents = [_]artifact.PCard{try pCard(ma, parent.hash, merged)};
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .manifest,
.a = null,
.b = if (delta) |d| d.b else null,
.c = .{ .comment = req.comment },
.d = .{ .timestamp = ts },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = r_card,
.u = .{ .login = req.user },
.z = null,
.w = null,
.f = if (tree_s.len != 0) &.{} else if (delta) |d| d.f else flat_f,
.j = &.{},
.m = &.{},
.p = if (parent.rid == null) &.{} else &parents,
.q = &.{},
.s = tree_s,
.t = tags,
};
defer m.deinit();
const plain = try artifact.create(&m, gpa);
defer gpa.free(plain);
// Before the hook: a resend of a check-in already stored is answered with
// its name, and a refused request runs no project check.
{
var pdigest: [digest_len]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(plain, &pdigest, .{});
if (ctx.log.lookup(&pdigest)) |held| if (!ctx.log.isPhantom(held)) {
var pname_buf: [64]u8 = undefined;
const held_name = Name.of(artifact.artifactName(.sha3_256, plain, &pname_buf));
reportFork(ctx, req, parent, held_name.slice());
return .{ .existing = held_name };
};
}
if (refusal) |e| {
diag.* = refusal_diag;
return e;
}
if (req.hooks.before_commit) |hook| hook(req.hooks.ctx) catch return error.HookRefused;
const sealed: ?[]u8 = if (req.hooks.seal) |seal| seal(req.hooks.ctx, gpa, plain) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.Refused => return signFailed(diag),
} else null;
defer if (sealed) |s| gpa.free(s);
if (sealed) |s| if (!std.mem.eql(u8, artifact.stripSignatureWrapper(s), plain)) return signFailed(diag);
const bytes: []const u8 = sealed orelse plain;
if (std.debug.runtime_safety) {
var check = artifact.parse(gpa, bytes) catch |e|
std.debug.panic("commit produced an unparseable manifest: {s}", .{@errorName(e)});
defer check.deinit();
std.debug.assert(check.kind == .manifest);
}
var mdigest: [digest_len]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(bytes, &mdigest, .{});
var mname_buf: [64]u8 = undefined;
const name = Name.of(artifact.artifactName(.sha3_256, bytes, &mname_buf));
if (ctx.log.lookup(&mdigest)) |held| if (!ctx.log.isPhantom(held)) {
reportFork(ctx, req, parent, name.slice());
return .{ .existing = name };
};
reportFork(ctx, req, parent, name.slice());
try publish(ctx, &pending, &staging, tree_s.len != 0, delta != null, &mdigest, bytes, checked.skip_base, base.rid, diag);
published = true;
ctx.repo_stale = true;
ctx.key = ctx.log.logKey() catch null;
rememberRenames(ctx, name, changes);
if (ctx.resend_index) |*index| index.add(gpa, timeKey(req.now), name) catch ctx.dropResendIndex();
return .{ .recorded = name };
}
/// Stored check-ins by their D-card time in milliseconds, for `findStored`.
/// Several can share a time; the list is short in practice and checked one
/// by one. True of the view it was built from; the holder drops it when that
/// view is replaced.
pub const StoredIndex = struct {
by_time: std.AutoHashMapUnmanaged(u64, std.ArrayList(Name)) = .empty,
pub fn deinit(self: *StoredIndex, gpa: std.mem.Allocator) void {
var it = self.by_time.valueIterator();
while (it.next()) |list| list.deinit(gpa);
self.by_time.deinit(gpa);
}
fn add(self: *StoredIndex, gpa: std.mem.Allocator, time: u64, name: Name) error{OutOfMemory}!void {
const got = try self.by_time.getOrPut(gpa, time);
if (!got.found_existing) got.value_ptr.* = .empty;
try got.value_ptr.append(gpa, name);
}
/// One pass over the view's events: O(check-ins) once, then O(1) a
/// lookup, and kept current by `commit` as it records.
pub fn build(gpa: std.mem.Allocator, repo: *Repository) error{OutOfMemory}!StoredIndex {
var index: StoredIndex = .{};
errdefer index.deinit(gpa);
const state = repo.state();
var events = state.eventsIter();
while (events.next()) |ev| {
const time = if (state.backing == .derived) state.originalMtimeOf(ev.rid) orelse ev.mtime else ev.mtime;
try index.add(gpa, time, .of(repo.hashOf(ev.rid)));
}
return index;
}
};
fn timeKey(t: Time) u64 {
std.debug.assert(t.epoch >= 0 and t.ms <= 999);
return @as(u64, @intCast(t.epoch)) * 1000 + t.ms;
}
/// A check-in as a resend names it: what `findStored` matches on.
pub const Identity = struct {
time: Time,
user: []const u8,
comment: []const u8,
/// Any name `resolve` accepts; null matches any parent.
parent: ?[]const u8 = null,
/// A merge's parents, primary first: matches only a check-in whose P
/// card lists exactly these, in this order. Given in place of `parent`.
parents: ?[]const []const u8 = null,
/// Null matches any branch.
branch: ?[]const u8 = null,
};
/// A stored check-in `findStored` matched, and what a commit that made it
/// reports.
pub const Stored = struct {
name: Name,
/// Empty for a root check-in.
parent: Name,
/// Borrows the view the match was made on.
branch: []const u8,
/// Its parent has another child on its branch (`Report.forked`).
forked: bool,
};
pub const FindError = error{ OutOfMemory, Storage, Corrupt };
/// The stored check-in with `id`'s D time (to the millisecond), user and
/// comment, on `id.branch` and built on `id.parent` when those are named.
/// The one test of "already stored" for both a resend (`find_resend`) and the
/// daemon's read-only lookup, which must never disagree. Reads `repo` and,
/// for a comment the view spilled, `log`; writes nothing and takes no lock.
pub fn findStored(repo: *Repository, log: *logstore.LogStore, index: *const StoredIndex, id: Identity, diag: *Diag) FindError!?Stored {
if (id.time.epoch < 0 or id.time.ms > 999) return null;
const candidates = index.by_time.get(timeKey(id.time)) orelse return null;
const primary_name: ?[]const u8 = if (id.parents) |ps| (if (ps.len == 0) return null else ps[0]) else id.parent;
const want_parent: ?repostate.Rid = if (primary_name) |p| (repo.resolve(p, .checkin) catch return null) else null;
for (candidates.items) |*name| {
const rid = repo.lookup(name.slice()) orelse continue;
const state = repo.state();
if (!std.mem.eql(u8, state.userOf(rid) orelse continue, id.user)) continue;
if (!try sameComment(repo, log, rid, name.slice(), id.comment, diag)) continue;
const branch = repo.branchOf(rid);
if (id.branch) |b| if (!std.mem.eql(u8, branch, b)) continue;
const parent_rid = state.primaryParentOf(rid);
if (want_parent) |wp| if (parent_rid != wp) continue;
if (id.parents) |ps| if (!try sameParents(repo, log, name.slice(), ps, diag)) continue;
var found: Stored = .{ .name = name.*, .parent = .{}, .branch = branch, .forked = false };
if (parent_rid) |p| {
found.parent = .of(repo.hashOf(p));
// A check-in that left its parent's branch started a branch
// rather than forking one.
if (std.mem.eql(u8, repo.branchOf(p), branch)) found.forked = hasOtherChildOn(repo, p, branch, name.slice());
}
return found;
}
return null;
}
/// The stored check-in this request already made, if any (`find_resend`),
/// with what it was built on in the report. Under the lock `commit` took.
fn findResend(ctx: *Context, req: Request, diag: *Diag) Error!?Name {
if (ctx.resend_index == null) ctx.resend_index = try StoredIndex.build(ctx.gpa, &ctx.repo);
const found = try findStored(&ctx.repo, &ctx.log, &ctx.resend_index.?, .{
.time = req.now,
.user = req.user,
.comment = req.comment,
.parent = req.parent,
.parents = req.parents,
.branch = req.branch,
}, diag) orelse return null;
if (req.report) |r| {
r.parent = found.parent;
r.branch = try r.arena.dupe(u8, found.branch);
r.forked = found.forked;
}
return found.name;
}
/// The view holds a check-in's comment unless it spilled it; then the
/// manifest is read, once per candidate, which only a time match reaches.
fn sameComment(repo: *Repository, log: *logstore.LogStore, rid: repostate.Rid, name: []const u8, comment: []const u8, diag: *Diag) FindError!bool {
if (repo.state().commentOf(rid)) |c| return std.mem.eql(u8, c, comment);
const gpa = log.gpa;
var digest_buf: [digest_len]u8 = undefined;
const digest = hexDigest(name, &digest_buf) orelse return false;
const lrid = log.lookup(digest) orelse return false;
const bytes = (log.content(gpa, lrid) catch |e| return contentError(e, diag)) orelse return false;
defer gpa.free(bytes);
var im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return false,
};
defer im.deinit(gpa);
const c = im.manifest.c orelse return comment.len == 0;
return std.mem.eql(u8, c.comment, comment);
}
/// The check-in `name`'s P card lists exactly the check-ins `want` names, in
/// order. Read from the manifest: the view's linkage rows do not keep the
/// card's order.
fn sameParents(repo: *Repository, log: *logstore.LogStore, name: []const u8, want: []const []const u8, diag: *Diag) FindError!bool {
const gpa = log.gpa;
var digest_buf: [digest_len]u8 = undefined;
const digest = hexDigest(name, &digest_buf) orelse return false;
const lrid = log.lookup(digest) orelse return false;
const bytes = (log.content(gpa, lrid) catch |e| return contentError(e, diag)) orelse return false;
defer gpa.free(bytes);
var im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return false,
};
defer im.deinit(gpa);
if (im.manifest.p.len == 0) return false;
var have = im.manifest.p[0].hashes();
for (want) |w| {
const h = have.next() orelse return false;
const rid = repo.resolve(w, .checkin) catch return false;
if (!std.mem.eql(u8, repo.hashOf(rid), h)) return false;
}
return have.next() == null;
}
/// `report.forked`: the parent has a child on its own branch other than
/// `own`, the check-in this request makes (a resend finds itself among the
/// children, and must not read as a fork of its own making).
fn reportFork(ctx: *Context, req: Request, parent: Parent, own: []const u8) void {
const r = req.report orelse return;
const prid = parent.rid orelse return;
if (parent.switch_branch != null) return;
if (hasOtherChildOn(&ctx.repo, prid, parent.branch, own)) r.forked = true;
}
/// `prid` has a child on `branch` other than `own`.
fn hasOtherChildOn(repo: *Repository, prid: repostate.Rid, branch: []const u8, own: []const u8) bool {
var children = repo.state().edgesOf(.children, prid);
var seen: usize = 0;
while (children.next()) |child| {
seen += 1;
std.debug.assert(seen <= repo.state().ridCount());
if (std.mem.eql(u8, repo.hashOf(child.rid), own)) continue;
if (std.mem.eql(u8, repo.branchOf(child.rid), branch)) return true;
}
return false;
}
fn signFailed(diag: *Diag) Error {
diag.set("--sign failed: the clearsign command errored or altered the manifest (set ZIT_PGP_COMMAND to override 'gpg --clearsign')", .{});
return error.SignFailed;
}
/// The request checks `commit` makes before it reads anything. A caller that
/// writes a working copy of the edits first (the CLI's before-commit area)
/// runs them itself, so a name the filesystem cannot hold is refused as the
/// name it is.
pub fn checkRequest(req: Request, diag: *Diag) Error!void {
if (req.parents) |ps| {
if (req.parent != null) {
diag.set("name the parent once: 'parent' or 'parents', not both", .{});
return error.InvalidRequest;
}
if (ps.len == 0) {
diag.set("'parents' names no check-in; the first is the primary parent", .{});
return error.InvalidRequest;
}
}
const merging = if (req.parents) |ps| ps.len > 1 else false;
if (req.edits.len == 0 and !merging) {
diag.set("nothing to commit (no edits)", .{});
return error.InvalidRequest;
}
if (req.branch) |b| {
if (b.len == 0) {
diag.set("--branch name must not be empty", .{});
return error.InvalidRequest;
}
if (control_paths.hasControlByte(b)) {
diag.set("--branch \"{f}\" holds a control byte; a check-in cannot carry it", .{std.zig.fmtString(b)});
return error.BadName;
}
}
if (std.mem.trim(u8, req.comment, " \t\r\n").len == 0) {
diag.set("aborting — empty check-in message", .{});
return error.InvalidRequest;
}
if (req.user.len == 0) {
diag.set("cannot determine the check-in author; pass --user <login>", .{});
return error.InvalidRequest;
}
if (control_paths.hasControlByte(req.user)) {
diag.set("--user \"{f}\" holds a control byte; a check-in cannot carry it", .{std.zig.fmtString(req.user)});
return error.BadName;
}
if (req.now.ms > 999) {
diag.set("the commit time's milliseconds must be 0..999, not {d}", .{req.now.ms});
return error.InvalidRequest;
}
for (req.edits) |e| switch (e) {
.put => |p| try checkPath(p.path, diag),
.delete => |p| try checkOldPath(p, diag),
.rename => |r| {
try checkOldPath(r.old, diag);
try checkPath(r.new, diag);
},
};
}
/// A delete, or a rename's old side, names a path already in the parent,
/// which stock Fossil may have minted with bytes no new name may hold; it
/// must still be removable. Only the reader's shape applies here, and the
/// parent's tree decides whether it exists.
fn checkOldPath(path: []const u8, diag: *Diag) Error!void {
if (artifact.isSimplePathname(path)) return;
diag.set("path \"{f}\" is not a path a check-in can hold", .{std.zig.fmtString(path)});
return error.InvalidRequest;
}
fn checkPath(path: []const u8, diag: *Diag) Error!void {
control_paths.validateNewPath(path) catch |e| switch (e) {
error.IllegalCharacters => {
diag.set("path \"{f}\" contains illegal characters", .{std.zig.fmtString(path)});
return error.BadName;
},
error.InvalidPath, error.ReservedPath => {
diag.set("'{s}' is not a path a check-in can hold", .{path});
return error.InvalidRequest;
},
};
}
fn resolveParent(ctx: *Context, ma: std.mem.Allocator, req: Request, diag: *Diag) Error!Parent {
const repo = &ctx.repo;
const prid: ?repostate.Rid = if (req.parent) |name| blk: {
const rid = repo.resolve(name, .checkin) catch |e| {
switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.NotFound => diag.set("check-in '{s}' not found", .{name}),
error.Ambiguous => diag.set("name '{s}' is ambiguous", .{name}),
}
return error.ParentNotFound;
};
break :blk repo.checkinRid(repo.hashOf(rid)) orelse {
diag.set("'{s}' is not a check-in", .{name});
return error.ParentNotFound;
};
} else if (repo.state().newestCheckin() == null) null else blk: {
// The tip of `branch` when that branch is open, else of the main
// branch.
if (req.branch) |b| if (try tipOf(ctx.gpa, repo, b)) |tip| break :blk tip;
const main_branch = repo.mainBranch();
if (try tipOf(ctx.gpa, repo, main_branch)) |tip| break :blk tip;
diag.set("branch '{s}' has no open leaf to build on; name a check-in with --parent", .{main_branch});
return error.NoOpenLeaf;
};
const rid = prid orelse return .{
.rid = null,
.hash = "",
.branch = try ma.dupe(u8, repo.mainBranch()),
.switch_branch = req.branch,
};
const branch = try ma.dupe(u8, repo.branchOf(rid));
return .{
.rid = rid,
.hash = try ma.dupe(u8, repo.hashOf(rid)),
.branch = branch,
// A check-in already on B stays there by inheritance; a branch is
// named only to move it onto B, where commit's rules for starting a
// branch apply.
.switch_branch = if (req.branch) |b| (if (std.mem.eql(u8, b, branch)) null else b) else null,
};
}
/// The full names of `req.parents` after the first, in order: the merge
/// parents the P card lists after `primary`. Empty for a commit with one
/// parent.
fn resolveMerged(ctx: *Context, ma: std.mem.Allocator, req: Request, primary: []const u8, diag: *Diag) Error![]const []const u8 {
const ps = req.parents orelse return &.{};
if (ps.len < 2) return &.{};
const repo = &ctx.repo;
const out = try ma.alloc([]const u8, ps.len - 1);
for (ps[1..], out, 1..) |name, *hash, i| {
const rid = repo.resolve(name, .checkin) catch |e| {
switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.NotFound => diag.set("merge parent '{s}' not found", .{name}),
error.Ambiguous => diag.set("name '{s}' is ambiguous", .{name}),
}
return error.ParentNotFound;
};
const full = repo.hashOf(rid);
if (repo.checkinRid(full) == null) {
diag.set("'{s}' is not a check-in", .{name});
return error.ParentNotFound;
}
// Fossil's P card holds a check-in once; a repeat would be a merge of
// a parent with itself.
if (std.mem.eql(u8, full, primary)) {
diag.set("parents[{d}] '{s}' is the primary parent again; a check-in is a parent once", .{ i, name });
return error.InvalidRequest;
}
for (out[0 .. i - 1]) |earlier| if (std.mem.eql(u8, earlier, full)) {
diag.set("parents[{d}] '{s}' names a merge parent already listed; a check-in is a parent once", .{ i, name });
return error.InvalidRequest;
};
hash.* = try ma.dupe(u8, full);
}
return out;
}
/// The P card: `primary`, then each merge parent, space-joined, as
/// `runCommitInner` composes it for a checkout with merges pending.
fn pCard(ma: std.mem.Allocator, primary: []const u8, merged: []const []const u8) error{OutOfMemory}!artifact.PCard {
if (merged.len == 0) return .{ .parents = primary, .count = 1 };
var tokens: std.ArrayList(u8) = .empty;
try tokens.appendSlice(ma, primary);
for (merged) |m| {
try tokens.append(ma, ' ');
try tokens.appendSlice(ma, m);
}
return .{ .parents = tokens.items, .count = 1 + merged.len };
}
/// The newest open leaf of `branch`, or null when it has none. A leaf, not
/// the name's resolution: several check-ins can share one timestamp, and
/// recency alone could then pick one that already has a child.
fn tipOf(gpa: std.mem.Allocator, repo: *Repository, branch: []const u8) error{OutOfMemory}!?repostate.Rid {
const leaves = try repo.state().openLeavesOf(gpa, branch);
defer gpa.free(leaves);
// Newest first, as `openLeavesOf` orders them.
return if (leaves.len == 0) null else leaves[0];
}
/// Read and parse the parent manifest. A sub-manifest parent stays a tree
/// when the editor will edit it (`edit_trees`), and is flattened otherwise;
/// a delta parent is merged with its baseline (§8.2) into its effective set.
fn loadBase(ctx: *Context, base: *Base, parent_hash: []const u8, edit_trees: bool, diag: *Diag) Error!void {
if (parent_hash.len == 0) return;
const gpa = ctx.gpa;
var digest_buf: [digest_len]u8 = undefined;
const digest = hexDigest(parent_hash, &digest_buf) orelse return parentCorrupt(diag, "the checkout names it in a form this build cannot decode");
const rid = ctx.log.lookup(digest) orelse return parentCorrupt(diag, "not present in this repository");
base.rid = rid;
base.bytes = (ctx.log.content(gpa, rid) catch |e| return contentError(e, diag)) orelse
return parentCorrupt(diag, "its content is unreadable");
// Prior (possibly fossil-origin) history: the legacy F-card tolerance.
const im = artifact.parseForImport(gpa, base.bytes.?) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return parentCorrupt(diag, @errorName(e)),
};
base.manifest = im.manifest;
base.normalized = im.normalized;
const pm = &base.manifest.?;
if (pm.kind != .manifest) return parentCorrupt(diag, "it is not a check-in manifest");
if (pm.s.len != 0) {
if (edit_trees) {
base.view = .{ .tree = pm.s };
return;
}
try flatten(ctx, pm, diag);
base.view = .{ .flat = pm.flattened.? };
return;
}
const b = pm.b orelse {
base.view = .{ .flat = pm.f };
return;
};
// §8.2/§8.3: a delta parent's own F run is a PATCH, not a file set.
var bdigest_buf: [digest_len]u8 = undefined;
const bdigest = hexDigest(b.hash, &bdigest_buf) orelse return parentCorrupt(diag, "its baseline is named in a form this build cannot decode");
const brid = ctx.log.lookup(bdigest) orelse return parentCorrupt(diag, "its baseline is not present in this repository");
base.baseline_bytes = (ctx.log.content(gpa, brid) catch |e| return contentError(e, diag)) orelse
return parentCorrupt(diag, "its baseline content is unreadable");
const bim = artifact.parseForImport(gpa, base.baseline_bytes.?) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return parentCorrupt(diag, @errorName(e)),
};
base.baseline = bim.manifest;
base.baseline_normalized = bim.normalized;
const bm = &base.baseline.?;
// A baseline that is itself a delta is merged one level, as Fossil does
// (§4.7.3); `compose.dialectBase` then keeps the check-in flat.
if (bm.kind != .manifest) return parentCorrupt(diag, "its baseline is not a manifest");
if (bm.s.len != 0) try flatten(ctx, bm, diag);
const BaselineCtx = struct {
bm: *const artifact.Manifest,
fn resolve(c: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
const self_ctx: *@This() = @ptrCast(@alignCast(c));
_ = hash; // one candidate, already matched to the B card above
return repository.manifestFilesOf(self_ctx.bm);
}
};
var bctx = BaselineCtx{ .bm = bm };
var walk = artifact.enumerate(repository.manifestFilesOf(pm), .{
.ctx = @ptrCast(&bctx),
.resolveFn = BaselineCtx.resolve,
}) catch return parentCorrupt(diag, "its baseline does not enumerate");
var effective: std.ArrayList(artifact.FCard) = .empty;
// The cards borrow the manifests; the list lives on the parent's arena
// so it is freed with them.
const owner = pm.arena_state.allocator();
while (walk.next()) |fc| {
if (fc.hash == null) continue; // merged delete markers carry no file
try effective.append(owner, fc);
}
base.view = .{ .flat = effective.items };
}
fn flatten(ctx: *Context, m: *artifact.Manifest, diag: *Diag) Error!void {
var missing = false;
Repository.flattenTreeFromLogDiagnose(ctx.gpa, &ctx.log, m, &missing) catch |e| {
if (missing) {
diag.set("the parent check-in is corrupt (a required sub-manifest is missing)", .{});
return error.MissingNode;
}
if (e == error.OutOfMemory) return error.OutOfMemory;
return parentCorrupt(diag, @errorName(e));
};
}
fn hexDigest(hex: []const u8, out: *[digest_len]u8) ?[]const u8 {
if (hex.len != 40 and hex.len != digest_len * 2) return null;
const n = hex.len / 2;
_ = std.fmt.hexToBytes(out[0..n], hex) catch return null;
return out[0..n];
}
/// Each edit as the path state it leaves, validated against the parent: the
/// same shape rules, unchanged-content rule and mode rule the working-tree
/// commit applies to a captured file. Empty when every edit matches the
/// parent.
fn collectChanges(
ctx: *Context,
ma: std.mem.Allocator,
base: *const Base,
req: Request,
diag: *Diag,
) Error![]const Change {
var changes: std.ArrayList(Change) = .empty;
var touched: std.StringHashMapUnmanaged(void) = .empty;
var removed: std.StringHashMapUnmanaged(void) = .empty;
// Paths a delete edit removed and no rename has taken yet: the one path
// two edits may name, a rename onto a file the same request deletes.
var deleted: std.StringHashMapUnmanaged(void) = .empty;
// Paths this commit adds to the tree: their shape is checked once every
// edit is known, since a later edit can collide with an earlier one.
var added: std.ArrayList([]const u8) = .empty;
for (try foldRenamePuts(ma, req.edits)) |e| switch (e) {
.put => |p| {
try touch(&touched, ma, p.path, diag);
if (p.bytes.len > max_file_bytes) return tooLarge(diag, p.path, p.bytes.len);
const current = try lookupParent(ctx, ma, base, p.path, diag);
if (current) |c| if (c.mode == .symlink) return notRegular(diag, p.path);
const mode = p.mode orelse if (current) |c| c.mode else .regular;
if (mode == .symlink) return notRegular(diag, p.path);
if (try putChange(ctx, ma, p.path, p.bytes, mode, current)) |change| try changes.append(ma, change);
if (current == null) try added.append(ma, p.path);
},
.delete => |path| {
try touch(&touched, ma, path, diag);
_ = (try lookupParent(ctx, ma, base, path, diag)) orelse return absentOrDirectory(ctx, ma, base, path, diag);
try changes.append(ma, .{ .path = path, .card = null });
try removed.put(ma, path, {});
try deleted.put(ma, path, {});
},
.rename => |r| {
if (std.mem.eql(u8, r.old, r.new)) {
diag.set("'{f}' cannot be renamed onto itself; nothing was written", .{std.zig.fmtString(r.old)});
return error.InvalidRequest;
}
try touch(&touched, ma, r.old, diag);
// NEW may be a file this request deletes: the rename replaces it.
const onto_deleted = deleted.remove(r.new);
if (!onto_deleted) try touch(&touched, ma, r.new, diag);
const source = (try lookupParent(ctx, ma, base, r.old, diag)) orelse return absentOrDirectory(ctx, ma, base, r.old, diag);
if (source.mode == .symlink) return notRegular(diag, r.old);
if (!onto_deleted and try lookupParent(ctx, ma, base, r.new, diag) != null) {
diag.set("'{f}' is already in the parent, so '{f}' cannot be renamed onto it; nothing was written", .{ std.zig.fmtString(r.new), std.zig.fmtString(r.old) });
return error.PathConflict;
}
const mode = r.mode orelse source.mode;
if (mode == .symlink) return notRegular(diag, r.new);
const bytes = r.bytes orelse try blobBytes(ctx, ma, source.hash.?, diag);
if (bytes.len > max_file_bytes) return tooLarge(diag, r.new, bytes.len);
// The working-tree commit captures NEW as a file the parent does
// not have: always freshly named, always policy-checked, no delta
// hint. The old name rides as the fourth token, which the grammar
// keeps only after a perm token, so a regular file carries `w`.
var name_buf: [64]u8 = undefined;
const name = try ma.dupe(u8, artifact.artifactName(.sha3_256, bytes, &name_buf));
try changes.append(ma, .{
.path = r.new,
.card = .{
.filename = r.new,
.hash = name,
.perm = if (mode == .executable) "x" else "w",
.old_name = r.old,
.mode = mode,
},
.bytes = bytes,
});
try changes.append(ma, .{ .path = r.old, .card = null });
try removed.put(ma, r.old, {});
try added.append(ma, r.new);
},
};
for (added.items) |path| try checkShape(ctx, ma, base, path, &touched, &removed, added.items, diag);
return changes.items;
}
/// A put of a rename's NEW name in the same request is that rename's new
/// content and mode: the edit `commit -R --rename OLD NEW --stdin NEW`
/// builds, so a client that sends the two separately records the same
/// check-in. Only a rename that is the one rename onto that name, with no
/// content of its own, takes a put, and only one; every other repeat of a
/// path is left for `touch` to refuse.
fn foldRenamePuts(ma: std.mem.Allocator, edits: []const Edit) error{OutOfMemory}![]const Edit {
const shared = std.math.maxInt(usize);
// NEW name to the index of the one rename onto it, or `shared`.
var onto: std.StringHashMapUnmanaged(usize) = .empty;
for (edits, 0..) |e, i| if (e == .rename) {
const got = try onto.getOrPut(ma, e.rename.new);
got.value_ptr.* = if (got.found_existing) shared else i;
};
if (onto.count() == 0) return edits;
var folded: ?[]Edit = null;
var dropped: []bool = &.{};
for (edits, 0..) |e, i| {
if (e != .put) continue;
const at = onto.get(e.put.path) orelse continue;
if (at == shared or edits[at].rename.bytes != null) continue;
if (folded == null) {
folded = try ma.dupe(Edit, edits);
dropped = try ma.alloc(bool, edits.len);
@memset(dropped, false);
}
const r = &folded.?[at].rename;
if (r.bytes != null) continue; // a second put of the name stays, and is refused
r.bytes = e.put.bytes;
r.mode = e.put.mode;
dropped[i] = true;
}
const out = folded orelse return edits;
var n: usize = 0;
for (out, dropped) |e, gone| if (!gone) {
out[n] = e;
n += 1;
};
return out[0..n];
}
/// The change a put makes, or null when it changes nothing. "Unchanged" is
/// decided by the ALGORITHM OF THE PARENT'S NAME, as Fossil decides it: a
/// SHA-1 parent's unchanged file keeps its SHA-1 name, and only new content
/// takes the SHA3 name.
fn putChange(
ctx: *Context,
ma: std.mem.Allocator,
path: []const u8,
bytes: []const u8,
mode: artifact.FileMode,
current: ?artifact.FCard,
) Error!?Change {
var name_buf: [64]u8 = undefined;
var same_content = false;
var base_rid: ?logstore.Rid = null;
if (current) |c| {
const have = c.hash.?;
const algo: artifact.HashAlgo = if (have.len == 40) .sha1 else .sha3_256;
same_content = std.mem.eql(u8, artifact.artifactName(algo, bytes, &name_buf), have);
if (same_content and mode == c.mode) return null;
var digest_buf: [digest_len]u8 = undefined;
if (hexDigest(have, &digest_buf)) |d| base_rid = ctx.log.lookup(d);
}
const name = if (same_content) current.?.hash.? else try ma.dupe(u8, artifact.artifactName(.sha3_256, bytes, &name_buf));
return .{
.path = path,
.card = .{
.filename = path,
.hash = name,
.perm = if (mode == .executable) "x" else null,
.old_name = null,
.mode = mode,
},
.bytes = if (same_content) null else bytes,
.base = base_rid,
};
}
fn touch(touched: *std.StringHashMapUnmanaged(void), ma: std.mem.Allocator, path: []const u8, diag: *Diag) Error!void {
const got = try touched.getOrPut(ma, path);
if (got.found_existing) {
diag.set("'{f}' is named by more than one edit", .{std.zig.fmtString(path)});
return error.InvalidRequest;
}
}
fn tooLarge(diag: *Diag, path: []const u8, size: usize) Error {
diag.set("cannot commit '{s}': {d} bytes exceeds the {d}-byte limit on one file", .{ path, size, max_file_bytes });
return error.TooLarge;
}
fn notRegular(diag: *Diag, path: []const u8) Error {
diag.set("'{f}' is a symbolic link in the parent; --stdin records regular files only", .{std.zig.fmtString(path)});
return error.PathConflict;
}
/// A delete or rename of a path the parent does not hold as a file: named as
/// a directory when the parent holds files under it, since edits name files
/// one by one and never expand a directory.
fn absentOrDirectory(ctx: *Context, ma: std.mem.Allocator, base: *const Base, path: []const u8, diag: *Diag) Error {
const no_removals: std.StringHashMapUnmanaged(void) = .empty;
const dir_prefix = try std.mem.concat(ma, u8, &.{ path, "/" });
if (try firstUnder(ctx, ma, base, dir_prefix, &no_removals, diag)) |inside| {
diag.set("'{f}' is a directory in the parent (it holds '{f}'); name its files one by one; nothing was written", .{ std.zig.fmtString(path), std.zig.fmtString(inside) });
return error.PathConflict;
}
return absent(diag, path);
}
fn absent(diag: *Diag, path: []const u8) Error {
diag.set("'{f}' is not in the parent; nothing was written", .{std.zig.fmtString(path)});
return error.PathConflict;
}
/// PATH must fit the new tree as a file: no proper ancestor of it a file, and
/// it not a directory. Either would mint a manifest holding both `d/f` and
/// `d/f/x`, which no checkout can materialize.
fn checkShape(
ctx: *Context,
ma: std.mem.Allocator,
base: *const Base,
path: []const u8,
touched: *const std.StringHashMapUnmanaged(void),
removed: *const std.StringHashMapUnmanaged(void),
added: []const []const u8,
diag: *Diag,
) Error!void {
var i: usize = 0;
while (std.mem.indexOfScalarPos(u8, path, i, '/')) |slash| : (i = slash + 1) {
const above = path[0..slash];
const in_parent = !removed.contains(above) and (try lookupParent(ctx, ma, base, above, diag)) != null;
if (in_parent or (touched.contains(above) and !removed.contains(above))) {
diag.set("'{f}' is a file in the parent, so '{f}' cannot be one under it; nothing was written", .{ std.zig.fmtString(above), std.zig.fmtString(path) });
return error.PathConflict;
}
}
const dir_prefix = try std.mem.concat(ma, u8, &.{ path, "/" });
for (added) |other| if (std.mem.startsWith(u8, other, dir_prefix)) {
diag.set("'{f}' is a directory in the parent (it holds '{f}'); nothing was written", .{ std.zig.fmtString(path), std.zig.fmtString(other) });
return error.PathConflict;
};
if (try firstUnder(ctx, ma, base, dir_prefix, removed, diag)) |inside| {
diag.set("'{f}' is a directory in the parent (it holds '{f}'); nothing was written", .{ std.zig.fmtString(path), std.zig.fmtString(inside) });
return error.PathConflict;
}
}
/// The parent's card for `path`, copied onto `ma`.
fn lookupParent(ctx: *Context, ma: std.mem.Allocator, base: *const Base, path: []const u8, diag: *Diag) Error!?artifact.FCard {
switch (base.view) {
.flat => |f| {
const at = lowerBound(f, path);
if (at < f.len and std.mem.eql(u8, f[at].filename, path)) return f[at];
return null;
},
.tree => |s| return lookupRoute(ctx, ma, s, path, 0, diag),
}
}
/// `path`'s card in the tree under `cards`, reading only the nodes on its
/// route: one per level. `TreeEditor.lookup` would answer the same, but on
/// an editor that has not yet seen this root it first validates every node
/// the root reaches (55 ms of a 42k-file tree); the editor's own build
/// validates each node it reads, so the commit loses no check.
fn lookupRoute(
ctx: *Context,
ma: std.mem.Allocator,
cards: []const artifact.SCard,
path: []const u8,
depth: usize,
diag: *Diag,
) Error!?artifact.FCard {
if (depth >= artifact.max_construct_height) return parentCorrupt(diag, "its sub-manifest tree is too deep");
var node = try loadNode(ctx, cards[childIndex(cards, path)].hash, diag);
defer node.deinit(ctx.gpa);
const m = &node.manifest;
if (m.s.len != 0) return lookupRoute(ctx, ma, m.s, path, depth + 1, diag);
const at = lowerBound(m.f, path);
if (at < m.f.len and std.mem.eql(u8, m.f[at].filename, path)) return try cloneCard(ma, m.f[at]);
return null;
}
/// The first parent path under `prefix` that this commit does not remove.
fn firstUnder(
ctx: *Context,
ma: std.mem.Allocator,
base: *const Base,
prefix: []const u8,
removed: *const std.StringHashMapUnmanaged(void),
diag: *Diag,
) Error!?[]const u8 {
switch (base.view) {
.flat => |f| {
var at = lowerBound(f, prefix);
while (at < f.len and std.mem.startsWith(u8, f[at].filename, prefix)) : (at += 1) {
if (!removed.contains(f[at].filename)) return f[at].filename;
}
return null;
},
.tree => |s| {
var target = prefix;
// Each round steps past one removed path, so the removals bound it.
var rounds: usize = 0;
while (rounds <= removed.count()) : (rounds += 1) {
const found = (try firstAtOrAfter(ctx, ma, s, target, 0, diag)) orelse return null;
if (!std.mem.startsWith(u8, found, prefix)) return null;
if (!removed.contains(found)) return found;
// NUL sorts below every byte a path can hold, so this is the
// least name after `found`.
target = try std.mem.concat(ma, u8, &.{ found, "\x00" });
}
unreachable;
},
}
}
/// The least path at or after `target` in the tree under `cards`, by a seek
/// along S-card ranges: one node per level, plus at most one right sibling
/// when the target lies past a node's last entry.
fn firstAtOrAfter(
ctx: *Context,
ma: std.mem.Allocator,
cards: []const artifact.SCard,
target: []const u8,
depth: usize,
diag: *Diag,
) Error!?[]const u8 {
if (depth >= artifact.max_construct_height) return parentCorrupt(diag, "its sub-manifest tree is too deep");
var j = childIndex(cards, target);
while (j < cards.len) : (j += 1) {
var node = try loadNode(ctx, cards[j].hash, diag);
defer node.deinit(ctx.gpa);
const m = &node.manifest;
if (m.s.len != 0) {
if (try firstAtOrAfter(ctx, ma, m.s, target, depth + 1, diag)) |found| return found;
} else {
const at = lowerBound(m.f, target);
if (at < m.f.len) return try ma.dupe(u8, m.f[at].filename);
}
}
return null;
}
const LoadedNode = struct {
bytes: editor.LoadedBytes,
manifest: artifact.Manifest,
fn deinit(self: *LoadedNode, gpa: std.mem.Allocator) void {
_ = gpa;
self.manifest.deinit();
self.bytes.deinit();
}
};
fn loadNode(ctx: *Context, name: []const u8, diag: *Diag) Error!LoadedNode {
return loadNodeFrom(ctx.gpa, ctx.tree_store.treeStore(), name, diag);
}
fn loadNodeFrom(gpa: std.mem.Allocator, store: editor.TreeStore, name: []const u8, diag: *Diag) Error!LoadedNode {
var bytes = store.load_fn(store.ctx, gpa, name) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.MissingNode => {
diag.set("the parent check-in is corrupt (a required sub-manifest is missing)", .{});
return error.MissingNode;
},
error.StorageFailure => {
diag.set("cannot write the check-in: input/output error", .{});
return error.Storage;
},
};
errdefer bytes.deinit();
var name_buf: [64]u8 = undefined;
const algo: artifact.HashAlgo = if (name.len == 40) .sha1 else .sha3_256;
if (!std.mem.eql(u8, artifact.artifactName(algo, bytes.bytes, &name_buf), name)) return parentCorrupt(diag, "a sub-manifest does not hash to its name");
const manifest = artifact.parse(gpa, bytes.bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return parentCorrupt(diag, @errorName(e)),
};
if (manifest.kind != .submanifest) {
var m = manifest;
m.deinit();
return parentCorrupt(diag, "a sub-manifest it names is not one");
}
return .{ .bytes = bytes, .manifest = manifest };
}
/// Index of the S card whose range holds `target`: the last card whose first
/// path is not past it, or the first card when the target precedes them all.
fn childIndex(cards: []const artifact.SCard, target: []const u8) usize {
std.debug.assert(cards.len != 0);
var low: usize = 0;
var high = cards.len;
while (low < high) {
const mid = low + (high - low) / 2;
if (artifact.compareBytes(cards[mid].filename, target) != .gt) low = mid + 1 else high = mid;
}
return if (low == 0) 0 else low - 1;
}
fn lowerBound(entries: []const artifact.FCard, path: []const u8) usize {
var low: usize = 0;
var high = entries.len;
while (low < high) {
const mid = low + (high - low) / 2;
if (artifact.compareBytes(entries[mid].filename, path) == .lt) low = mid + 1 else high = mid;
}
return low;
}
fn cloneCard(ma: std.mem.Allocator, c: artifact.FCard) error{OutOfMemory}!artifact.FCard {
return .{
.filename = try ma.dupe(u8, c.filename),
.hash = if (c.hash) |h| try ma.dupe(u8, h) else null,
.perm = if (c.perm) |p| try ma.dupe(u8, p) else null,
.old_name = if (c.old_name) |o| try ma.dupe(u8, o) else null,
.mode = c.mode,
};
}
fn blobBytes(ctx: *Context, ma: std.mem.Allocator, hash: []const u8, diag: *Diag) Error![]const u8 {
var digest_buf: [digest_len]u8 = undefined;
const digest = hexDigest(hash, &digest_buf) orelse return parentCorrupt(diag, "a parent F card names a blob in an undecodable form");
const rid = ctx.log.lookup(digest) orelse {
diag.set("a renamed file's content is elided by this store's cone; it is not here to record", .{});
return error.ElidedByCone;
};
const bytes = (ctx.log.content(ma, rid) catch |e| return contentError(e, diag)) orelse
return parentCorrupt(diag, "a parent F card's blob is unreadable");
return bytes;
}
/// A carried card loses its one-shot rename provenance (§4.7.6), exactly as
/// `compose.composeFCards` drops it, along with the `w` placeholder a regular
/// file carried only to keep the old-name token in its field.
fn stripped(c: artifact.FCard) artifact.FCard {
var carried = c;
carried.old_name = null;
if (carried.mode == .regular and carried.perm != null and std.mem.eql(u8, carried.perm.?, "w")) carried.perm = null;
return carried;
}
fn stripRenames(ma: std.mem.Allocator, f: []const artifact.FCard) error{OutOfMemory}![]const artifact.FCard {
for (f) |c| {
if (c.old_name != null) break;
} else return f;
const out = try ma.alloc(artifact.FCard, f.len);
for (f, out) |c, *o| o.* = if (c.old_name != null) stripped(c) else c;
return out;
}
/// A flat check-in's F run: the parent's cards minus what this commit removes,
/// plus its new cards, through the same composition the working-tree commit
/// uses.
fn composeFlat(ma: std.mem.Allocator, parent: []const artifact.FCard, changes: []const Change) error{OutOfMemory}![]const artifact.FCard {
var removed: std.StringHashMapUnmanaged(void) = .empty;
var captured: std.ArrayList(artifact.FCard) = .empty;
for (changes) |c| {
if (c.card) |card| try captured.append(ma, card) else try removed.put(ma, c.path, {});
}
var kept: std.ArrayList(artifact.FCard) = .empty;
try kept.ensureTotalCapacity(ma, parent.len);
for (parent) |p| if (!removed.contains(p.filename)) kept.appendAssumeCapacity(p);
const composed = try compose.composeFCards(ma, .{
.parent = kept.items,
.captured = captured.items,
.policy = .carry_forward,
});
return composed.f;
}
/// The editor commands for `changes`, plus one per parent card that still
/// carries rename provenance and that no change replaces: the carried card
/// sheds it, as `stripped` describes.
fn treeCommands(
ctx: *Context,
ma: std.mem.Allocator,
base: *const Base,
parent_hash: []const u8,
changes: []const Change,
diag: *Diag,
) Error![]const editor.TreeCommand {
var commands: std.ArrayList(editor.TreeCommand) = .empty;
var touched: std.StringHashMapUnmanaged(void) = .empty;
for (changes) |c| {
try touched.put(ma, c.path, {});
try commands.append(ma, if (c.card) |card| .{ .upsert = card } else .{ .delete = c.path });
}
switch (base.view) {
.flat => {}, // `stripRenames` sheds it from the flat root itself
.tree => |s| for (try parentRenames(ctx, ma, s, parent_hash, diag)) |card| {
if (touched.contains(card.filename)) continue;
try commands.append(ma, .{ .upsert = stripped(card) });
},
}
return commands.items;
}
/// The parent tree's cards that carry an old-name. Known without a walk when
/// this context recorded the parent; otherwise found by reading every node
/// (the leaves are only scanned for a fourth token, never parsed unless one
/// appears).
fn parentRenames(ctx: *Context, ma: std.mem.Allocator, cards: []const artifact.SCard, parent_hash: []const u8, diag: *Diag) Error![]const artifact.FCard {
if (ctx.renamed_memo) |*memo| if (std.mem.eql(u8, memo.checkin.slice(), parent_hash)) return memo.cards;
var found: std.ArrayList(artifact.FCard) = .empty;
try scanRenames(ctx, ma, cards, 0, &found, diag);
return found.items;
}
fn scanRenames(
ctx: *Context,
ma: std.mem.Allocator,
cards: []const artifact.SCard,
depth: usize,
found: *std.ArrayList(artifact.FCard),
diag: *Diag,
) Error!void {
if (depth >= artifact.max_construct_height) return parentCorrupt(diag, "its sub-manifest tree is too deep");
const store = ctx.tree_store.treeStore();
for (cards) |card| {
var bytes = store.load_fn(store.ctx, ctx.gpa, card.hash) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.MissingNode => {
diag.set("the parent check-in is corrupt (a required sub-manifest is missing)", .{});
return error.MissingNode;
},
error.StorageFailure => {
diag.set("cannot write the check-in: input/output error", .{});
return error.Storage;
},
};
defer bytes.deinit();
if (!isInterior(bytes.bytes) and !hasRenamedCard(bytes.bytes)) continue;
var m = artifact.parse(ctx.gpa, bytes.bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return parentCorrupt(diag, @errorName(e)),
};
defer m.deinit();
if (m.s.len != 0) {
try scanRenames(ctx, ma, m.s, depth + 1, found, diag);
continue;
}
for (m.f) |f| if (f.old_name != null) try found.append(ma, try cloneCard(ma, f));
}
}
fn isInterior(bytes: []const u8) bool {
return std.mem.startsWith(u8, bytes, "S ") or std.mem.indexOf(u8, bytes, "\nS ") != null;
}
/// Whether some F line holds a fourth token. Filenames are §4.3-escaped, so a
/// space separates tokens and nothing else: four spaces mean name, hash,
/// perm and old-name.
fn hasRenamedCard(bytes: []const u8) bool {
var lines = std.mem.splitScalar(u8, bytes, '\n');
while (lines.next()) |line| {
if (!std.mem.startsWith(u8, line, "F ")) continue;
if (std.mem.count(u8, line, " ") >= 4) return true;
}
return false;
}
fn rememberRenames(ctx: *Context, checkin: Name, changes: []const Change) void {
if (ctx.renamed_memo) |*m| m.arena.deinit();
ctx.renamed_memo = null;
var arena = std.heap.ArenaAllocator.init(ctx.gpa);
var cards: std.ArrayList(artifact.FCard) = .empty;
for (changes) |c| {
const card = c.card orelse continue;
if (card.old_name == null) continue;
const copy = cloneCard(arena.allocator(), card) catch {
arena.deinit();
return;
};
cards.append(arena.allocator(), copy) catch {
arena.deinit();
return;
};
}
ctx.renamed_memo = .{ .checkin = checkin, .arena = arena, .cards = cards.items };
}
fn treeError(e: editor.Error, diag: *Diag) Error {
switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.MissingNode => {
diag.set("the parent check-in is corrupt (a required sub-manifest is missing)", .{});
return error.MissingNode;
},
error.StorageFailure => {
diag.set("cannot write the check-in: input/output error", .{});
return error.Storage;
},
else => return parentCorrupt(diag, @errorName(e)),
}
}
/// The R card (§6.3) over the new check-in's complete file list, in order:
/// the F run, or the walk of the new tree (§5.5). Content comes from the
/// store keyed by each card's hash, never from the edits a second time.
/// Over a sparse store an elided carried blob makes the aggregate
/// uncomputable, and R is omitted rather than fabricated (sparse-cone RFC
/// section 4.3).
fn checksumCard(
ctx: *Context,
ma: std.mem.Allocator,
staging: *StagingStore,
pending: *const PendingBlobs,
flat_f: []const artifact.FCard,
tree_s: []const artifact.SCard,
out: *[32]u8,
diag: *Diag,
) Error!?artifact.RCard {
var entries: std.ArrayList(artifact.FCard) = .empty;
if (tree_s.len != 0) try treeCards(ctx.gpa, ma, staging.treeStore(), tree_s, 0, &entries, diag) else try entries.appendSlice(ma, flat_f);
var agg: artifact.ChecksumAggregator = .{};
for (entries.items) |f| {
const fhash = f.hash orelse return parentCorrupt(diag, "a parent F card carries no hash");
var fdigest_buf: [digest_len]u8 = undefined;
const fdigest = hexDigest(fhash, &fdigest_buf) orelse return parentCorrupt(diag, "a parent F card names a blob in an undecodable form");
if (pending.get(fdigest)) |held| {
agg.update(f.filename, held);
continue;
}
const frid = ctx.log.lookup(fdigest) orelse {
if (repocache.readStoreCone(ctx.gpa, ctx.io, ctx.repo.root_dir)) |c| {
var cone = c;
defer cone.deinit(ctx.gpa);
if (!cone.covers(f.filename)) return null;
}
return parentCorrupt(diag, "a parent F card names a blob this repository lacks");
};
const fbytes = (ctx.log.content(ctx.gpa, frid) catch |e| return contentError(e, diag)) orelse
return parentCorrupt(diag, "a parent F card's blob is unreadable");
defer ctx.gpa.free(fbytes);
agg.update(f.filename, fbytes);
}
return .{ .checksum = agg.final(out) };
}
/// Every F card of the tree under `cards`, in walk order, copied onto `ma`.
fn treeCards(
gpa: std.mem.Allocator,
ma: std.mem.Allocator,
store: editor.TreeStore,
cards: []const artifact.SCard,
depth: usize,
out: *std.ArrayList(artifact.FCard),
diag: *Diag,
) Error!void {
if (depth >= artifact.max_construct_height) return parentCorrupt(diag, "its sub-manifest tree is too deep");
for (cards) |card| {
var node = try loadNodeFrom(gpa, store, card.hash, diag);
defer node.deinit(gpa);
if (node.manifest.s.len != 0) {
try treeCards(gpa, ma, store, node.manifest.s, depth + 1, out, diag);
} else for (node.manifest.f) |f| try out.append(ma, try cloneCard(ma, f));
}
}
/// Append everything the check-in needs, the manifest last, and make it
/// durable in one fsync: blobs, then sub-manifests (stored before the
/// manifest that references them, submanifests §6.2), then the indicators
/// that describe them, then the manifest delta-encoded against its skip base.
fn publish(
ctx: *Context,
pending: *const PendingBlobs,
staging: *const StagingStore,
is_tree: bool,
is_delta: bool,
mdigest: *const [digest_len]u8,
bytes: []const u8,
skip_base: ?[]const u8,
parent_rid: ?logstore.Rid,
diag: *Diag,
) Error!void {
const gpa = ctx.gpa;
for (pending.items.items) |it| {
_ = ctx.log.putArtifact(&it.digest, it.bytes, it.base) catch |e| return putError(e, diag);
}
if (staging.nodes.items.len != 0) {
const nodes = try gpa.alloc(editor.StoreBatchNode, staging.nodes.items.len);
defer gpa.free(nodes);
for (staging.nodes.items, nodes) |n, *out| out.* = .{ .name = n.name, .bytes = n.bytes, .f_cards = n.f_cards, .base = n.base };
const store = ctx.tree_store.treeStore();
store.put_batch_fn(store.ctx, nodes) catch |e| return putError(e, diag);
}
// submanifests §8.1: the seen-submanifest indicator arms the §7.2
// transfer gate even after the setting is disabled again.
if (is_tree) setSeenSubmanifest(gpa, &ctx.log) catch |e| return putError(e, diag);
if (is_delta) setSeenDelta(gpa, &ctx.log) catch |e| return putError(e, diag);
const skip_base_rid: ?logstore.Rid = if (skip_base) |name| blk: {
var sdigest_buf: [digest_len]u8 = undefined;
const sdigest = hexDigest(name, &sdigest_buf) orelse break :blk null;
break :blk ctx.log.lookup(sdigest);
} else null;
_ = ctx.log.putArtifactSkipBase(mdigest, bytes, skip_base_rid, parent_rid) catch |e| return putError(e, diag);
// A batch syncs once, at `endBatch`.
if (!ctx.batching) ctx.log.commit() catch |e| return putError(e, diag);
}
/// The tree editor's writes for one commit, held until the commit publishes:
/// the before-commit hook and the signer run after the tree is built, and a
/// refusal from either must leave the store as it was. Reads fall through to
/// the log for every node not built here.
const StagingStore = struct {
gpa: std.mem.Allocator,
base: editor.TreeStore,
nodes: std.ArrayList(Node) = .empty,
index: std.StringHashMapUnmanaged(usize) = .empty,
const Node = struct {
name: []u8,
bytes: []u8,
f_cards: usize,
base: ?[]u8,
};
fn init(gpa: std.mem.Allocator, base: editor.TreeStore) StagingStore {
return .{ .gpa = gpa, .base = base };
}
fn deinit(self: *StagingStore) void {
self.index.deinit(self.gpa);
for (self.nodes.items) |n| {
self.gpa.free(n.name);
self.gpa.free(n.bytes);
if (n.base) |b| self.gpa.free(b);
}
self.nodes.deinit(self.gpa);
self.* = undefined;
}
fn treeStore(self: *StagingStore) editor.TreeStore {
return .{ .ctx = self, .load_fn = load, .put_fn = put, .put_batch_fn = putBatch };
}
fn load(ctx: *anyopaque, allocator: std.mem.Allocator, name: []const u8) editor.StoreError!editor.LoadedBytes {
const self: *StagingStore = @ptrCast(@alignCast(ctx));
if (self.index.get(name)) |at| {
const bytes = allocator.dupe(u8, self.nodes.items[at].bytes) catch return error.OutOfMemory;
return .{ .bytes = bytes, .allocator = allocator };
}
return self.base.load_fn(self.base.ctx, allocator, name);
}
fn put(ctx: *anyopaque, name: []const u8, bytes: []const u8) editor.StoreError!void {
const self: *StagingStore = @ptrCast(@alignCast(ctx));
return self.stage(.{ .name = name, .bytes = bytes, .f_cards = 0 });
}
fn putBatch(ctx: *anyopaque, nodes: []const editor.StoreBatchNode) editor.StoreError!void {
const self: *StagingStore = @ptrCast(@alignCast(ctx));
for (nodes) |node| try self.stage(node);
}
/// Names are checked against their bytes where they are written, by the
/// log's store (`LogTreeStore`), not here.
fn stage(self: *StagingStore, node: editor.StoreBatchNode) editor.StoreError!void {
if (self.index.get(node.name)) |at| {
if (!std.mem.eql(u8, self.nodes.items[at].bytes, node.bytes)) return error.StorageFailure;
return;
}
try self.nodes.ensureUnusedCapacity(self.gpa, 1);
try self.index.ensureUnusedCapacity(self.gpa, 1);
const name = try self.gpa.dupe(u8, node.name);
errdefer self.gpa.free(name);
const bytes = try self.gpa.dupe(u8, node.bytes);
errdefer self.gpa.free(bytes);
const base = if (node.base) |b| try self.gpa.dupe(u8, b) else null;
self.index.putAssumeCapacityNoClobber(name, self.nodes.items.len);
self.nodes.appendAssumeCapacity(.{ .name = name, .bytes = bytes, .f_cards = node.f_cards, .base = base });
}
};
test "hasRenamedCard: four spaces on an F line mean an old-name token, escaped names never add one" {
try std.testing.expect(!hasRenamedCard("F a\\sb.txt 0123 x\nZ 00\n"));
try std.testing.expect(hasRenamedCard("F new.txt 0123 w old.txt\nZ 00\n"));
try std.testing.expect(!hasRenamedCard("C a\\sb\\sc\\sd\\se\nF a 1\n"));
}
test "composeFlat: a delete and a rename leave the parent; the rename keeps its provenance, a carried one sheds it" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const ma = arena_state.allocator();
const parent = [_]artifact.FCard{
.{ .filename = "a", .hash = "1", .perm = null, .old_name = null, .mode = .regular },
.{ .filename = "b", .hash = "2", .perm = "w", .old_name = "z", .mode = .regular },
.{ .filename = "c", .hash = "3", .perm = null, .old_name = null, .mode = .regular },
};
const changes = [_]Change{
.{ .path = "a", .card = null },
.{ .path = "d", .card = .{ .filename = "d", .hash = "3", .perm = "w", .old_name = "c", .mode = .regular } },
.{ .path = "c", .card = null },
};
const f = try composeFlat(ma, &parent, &changes);
try std.testing.expectEqual(@as(usize, 2), f.len);
try std.testing.expectEqualStrings("b", f[0].filename);
try std.testing.expect(f[0].old_name == null and f[0].perm == null);
try std.testing.expectEqualStrings("d", f[1].filename);
try std.testing.expectEqualStrings("c", f[1].old_name.?);
try std.testing.expectEqualStrings("w", f[1].perm.?);
}
test "childIndex: the S card whose range holds the target, or the first" {
const cards = [_]artifact.SCard{
.{ .filename = "b", .hash = "1" },
.{ .filename = "m", .hash = "2" },
.{ .filename = "t", .hash = "3" },
};
try std.testing.expectEqual(@as(usize, 0), childIndex(&cards, "a"));
try std.testing.expectEqual(@as(usize, 0), childIndex(&cards, "b"));
try std.testing.expectEqual(@as(usize, 1), childIndex(&cards, "m/x"));
try std.testing.expectEqual(@as(usize, 2), childIndex(&cards, "zz"));
}
test "Diag: a message longer than the buffer is cut, never overflowed" {
var diag: Diag = .{};
const long: [Diag.diag_max + 100]u8 = @splat('x');
diag.set("{s}", .{&long});
try std.testing.expectEqual(@as(usize, Diag.diag_max), diag.text().len);
diag.clear();
try std.testing.expectEqualStrings("", diag.text());
}
test "commit repo-state §6.2: only inherited (propagating) parent syms are cancelled" {
const gpa = std.testing.allocator;
const TagEntry = repostate.RepoState.TagEntry;
// A parent (as `tagsOf` surfaces it: active rows only) carrying every case:
// an inherited propagating sym (MUST cancel), the branch's own propagating
// sym (excluded — re-established), a SINGLETON sym (MUST NOT cancel; §6.2
// cancels only inherited syms, and a `+` sym never propagates), and a
// non-sym propagating tag (`branch`, not a symbolic name).
const entries = [_]TagEntry{
.{ .name = "sym-trunk", .tagtype = .propagate, .value = null },
.{ .name = "sym-feature", .tagtype = .propagate, .value = null },
.{ .name = "sym-adhoc", .tagtype = .singleton, .value = null },
.{ .name = "branch", .tagtype = .propagate, .value = "trunk" },
};
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
const got = try selectInheritedSyms(arena_state.allocator(), &entries, "feature");
// Exactly the inherited propagating sym, minus the re-established one: the
// singleton `sym-adhoc` and the non-sym `branch` are both excluded.
try std.testing.expectEqual(@as(usize, 1), got.len);
try std.testing.expectEqualStrings("sym-trunk", got[0]);
}