//! RED coverage for Task 10's transactional `zit import-git` command.
//!
//! This file deliberately names the smallest testable production seam that does
//! not execute a shell command or contact a remote:
//!
//! `git_import_cli.run(gpa, io, request, streams, dependencies) -> cli.Exit`
//!
//! `dependencies` supplies a `ChildFactory`/`ChildSession` and filesystem hooks.
//! A `ChildSession` must expose its stdout reader plus `terminate`, `drainStderr`,
//! and `reap`; each lifecycle operation is observable in the supplied fake. The
//! real CLI dispatch will construct OS-backed dependencies. The filesystem hook
//! is only needed to deterministically fail publication flush/close/sync/rename
//! boundaries; ordinary scenarios exercise a real `std.testing.tmpDir`.
//!
//! The opt-in suite intentionally remains RED until Task 10 provides command
//! preflight, real-pipe lifecycle, and publication-boundary fault seams. It is
//! not part of `zig build test`.
const std = @import("std");
const builtin = @import("builtin");
const cli = @import("zit").cli;
const fast_export = @import("zit").git_fast_export;
const import_cli = @import("git_import_cli.zig");
const checkout = @import("zit").checkout;
const sync = @import("zit").sync;
const artifact = @import("zit").artifact;
const git_import = @import("zit").git_import;
const logstore = @import("zit").logstore;
const storage = @import("zit").storage;
const pack = @import("zit").pack;
const helper_options = @import("git_import_cli_options");
const valid_export =
"feature done\n" ++
"blob\n" ++
"mark :1\n" ++
"data 5\n" ++
"hello\n" ++
"commit refs/heads/main\n" ++
"mark :2\n" ++
"author A <[email protected]> 0 +0000\n" ++
"committer A <[email protected]> 0 +0000\n" ++
"data 4\n" ++
"init\n" ++
"M 100644 :1 README\n" ++
"\n" ++
"done\n";
const valid_export_with_remote_ref =
"feature done\n" ++
"blob\n" ++
"mark :1\n" ++
"data 5\n" ++
"hello\n" ++
"commit refs/heads/main\n" ++
"mark :2\n" ++
"author A <[email protected]> 0 +0000\n" ++
"committer A <[email protected]> 0 +0000\n" ++
"data 4\n" ++
"init\n" ++
"M 100644 :1 README\n" ++
"\n" ++
"reset refs/remotes/origin/main\n" ++
"from :2\n" ++
"\n" ++
"done\n";
const valid_export_with_oids =
"feature done\n" ++
"blob\n" ++
"mark :1\n" ++
"original-oid 100b93820ade4c16225673b4ca62bb3ade63c313\n" ++
"data 5\n" ++
"hello\n" ++
"commit refs/heads/main\n" ++
"mark :2\n" ++
"original-oid ed9b38a10070b504cecfacd892507af78fcb24a4\n" ++
"author A <[email protected]> 0 +0000\n" ++
"committer A <[email protected]> 0 +0000\n" ++
"data 4\n" ++
"init\n" ++
"M 100644 :1 README\n" ++
"\n" ++
"done\n";
/// What the source exports once `valid_export_with_oids` is in a destination:
/// one new commit on main whose parent is named by OID, as an export that
/// excludes the known tips names it.
const added_export_with_oids =
"feature done\n" ++
"blob\n" ++
"mark :1\n" ++
"original-oid 2222222222222222222222222222222222222222\n" ++
"data 6\n" ++
"hello2\n" ++
"commit refs/heads/main\n" ++
"mark :2\n" ++
"original-oid 3333333333333333333333333333333333333333\n" ++
"author A <[email protected]> 1 +0000\n" ++
"committer A <[email protected]> 1 +0000\n" ++
"data 4\n" ++
"next\n" ++
"from ed9b38a10070b504cecfacd892507af78fcb24a4\n" ++
"M 100644 :1 README\n" ++
"\n" ++
"done\n";
const malformed_export =
"feature done\n" ++
"commit refs/heads/main\n" ++
"mark :not-a-number\n";
const marked_tag_export =
"feature done\n" ++
"blob\n" ++
"mark :1\n" ++
"data 5\n" ++
"hello\n" ++
"commit refs/heads/main\n" ++
"mark :2\n" ++
"author A <[email protected]> 0 +0000\n" ++
"committer A <[email protected]> 0 +0000\n" ++
"data 4\n" ++
"init\n" ++
"M 100644 :1 README.md\n" ++
"\n" ++
"tag v1\n" ++
"mark :3\n" ++
"from :2\n" ++
"tagger A <[email protected]> 0 +0000\n" ++
"data 0\n" ++
"\n" ++
"done\n";
const Result = struct {
code: cli.Exit,
out: []const u8,
err: []const u8,
lifecycle: import_cli.ChildLifecycle,
};
/// The wished test adapter. Task 10's production implementation owns no fake
/// process: it consumes this narrow interface, while the CLI passes the
/// OS-backed implementation and this file passes a scripted one.
const LifecycleFault = enum { terminate, drain_stderr, reap };
const ScriptedGit = struct {
fast_export: []const u8,
export_exit: u8 = 0,
stderr: []const u8 = "",
launched: usize = 0,
terminated: usize = 0,
stderr_drained: usize = 0,
reaped: usize = 0,
lifecycle_fault: ?LifecycleFault = null,
limits: import_cli.ImportLimits = .{},
/// What preflight's §6.3 analysis would have returned. Null is "the
/// analysis produced nothing", which is what every test that does not care
/// about the analyzed path wants.
analyzed_submanifest_k: ?u4 = null,
fn dependencies(self: *ScriptedGit, publication: import_cli.PublicationHooks) import_cli.Dependencies {
return .{
.git = .{
.context = self,
.vtable = &.{
.start_export = startExport,
.describe_source = describeSource,
.terminate = terminate,
.drain_stderr = drainStderr,
.reap = reap,
},
},
.publication = publication,
.limits = self.limits,
};
}
fn describeSource(_: *anyopaque, gpa: std.mem.Allocator, _: []const u8) !import_cli.SourceIdentity {
return .{ .object_format = try gpa.dupe(u8, "sha1"), .roots = try gpa.alloc([]u8, 0), .refs = try gpa.alloc(import_cli.SourceRef, 0) };
}
fn startExport(context: *anyopaque, _: []const u8, _: ?[]const []const u8) !import_cli.ExportPipes {
const self: *ScriptedGit = @ptrCast(@alignCast(context));
self.launched += 1;
return .{
.stdout = .fixed(self.fast_export),
.stderr = .fixed(self.stderr),
.exit_code = self.export_exit,
.analyzed_submanifest_k = self.analyzed_submanifest_k,
};
}
fn terminate(context: *anyopaque) !void {
const self: *ScriptedGit = @ptrCast(@alignCast(context));
self.terminated += 1;
if (self.lifecycle_fault == .terminate) return error.InjectedLifecycleFailure;
}
fn drainStderr(context: *anyopaque) !void {
const self: *ScriptedGit = @ptrCast(@alignCast(context));
self.stderr_drained += 1;
if (self.lifecycle_fault == .drain_stderr) return error.InjectedLifecycleFailure;
}
fn reap(context: *anyopaque) !void {
const self: *ScriptedGit = @ptrCast(@alignCast(context));
self.reaped += 1;
if (self.lifecycle_fault == .reap) return error.InjectedLifecycleFailure;
}
};
fn runFixture(
tmp: std.testing.TmpDir,
source: []const u8,
destination: []const u8,
quiet: bool,
git: *ScriptedGit,
publication: import_cli.PublicationHooks,
) Result {
return runFixtureShaped(tmp, source, destination, quiet, git, publication, .{ .tree = 5 });
}
/// `runFixture` with the one request field these tests vary. Everything else --
/// source, dependencies, streams, attribution, default branch -- is held
/// identical on purpose: the whole point of the flag is that flat and tree
/// imports differ in tree emission and in nothing else.
fn runFixtureShaped(
tmp: std.testing.TmpDir,
source: []const u8,
destination: []const u8,
quiet: bool,
git: *ScriptedGit,
publication: import_cli.PublicationHooks,
submanifest: git_import.SubmanifestChoice,
) Result {
return runFixtureFull(tmp, source, destination, quiet, git, publication, submanifest, false);
}
/// The whole request surface these tests reach. `provenance` mirrors the
/// production default (off): a test that reads the published map or refs opts
/// in exactly as an operator would.
fn runFixtureFull(
tmp: std.testing.TmpDir,
source: []const u8,
destination: []const u8,
quiet: bool,
git: *ScriptedGit,
publication: import_cli.PublicationHooks,
submanifest: git_import.SubmanifestChoice,
provenance: bool,
) Result {
const Capture = struct {
var out_storage: [4096]u8 = undefined;
var err_storage: [4096]u8 = undefined;
};
var out = std.Io.Writer.fixed(&Capture.out_storage);
var err = std.Io.Writer.fixed(&Capture.err_storage);
const streams: cli.Streams = .{ .out = &out, .err = &err };
const code = import_cli.run(
std.testing.allocator,
std.testing.io,
.{
.source = source,
.destination_parent = tmp.dir,
.destination_name = destination,
.project_code = sync.deriveProjectCode(&.{destination}),
.submanifest = submanifest,
.attribution = .committer,
.default_branch = null,
.quiet = quiet,
.provenance = provenance,
},
streams,
git.dependencies(publication),
);
return .{
.code = code,
.out = out.buffered(),
.err = err.buffered(),
.lifecycle = .{
.launched = git.launched,
.terminated = git.terminated,
.stderr_drained = git.stderr_drained,
.reaped = git.reaped,
},
};
}
fn retainedBytesForExport(bytes: []const u8) !u64 {
var reader = fast_export.Reader.init(std.testing.allocator, .fixed(bytes), .{
.max_line = 1024 * 1024,
.max_data = 1024 * 1024 * 1024,
.max_path = 4096,
.max_events = std.math.maxInt(u64),
});
defer reader.deinit();
var retained_bytes: u64 = 0;
while (try reader.next()) |lease| {
const charge = blk: {
var event = lease;
defer event.deinit();
break :blk try import_cli.retainedByteCharge(event.value);
};
retained_bytes = try std.math.add(u64, retained_bytes, charge);
}
return retained_bytes;
}
/// How far a fast-export stream gets before it stops, and how it stopped.
const ExportReach = struct { events: usize, rejected: bool };
/// Replays `bytes` through the same reader the import uses.
///
/// This is the discriminator the two export-fault tests are named for, and
/// `create_store` cannot supply it: that boundary is checked BEFORE
/// `createRawGeneration`, so a stream rejected at record zero and a stream
/// truncated after a blob observe it identically. The count of records this
/// reader accepts is what separates them -- at least one accepted record means
/// the failure lands with generation 1 open and already written, which is the
/// window those tests claim and the harder one to unwind.
fn exportReach(bytes: []const u8) !ExportReach {
var reader = fast_export.Reader.init(std.testing.allocator, .fixed(bytes), .{
.max_line = 1024 * 1024,
.max_data = 1024 * 1024 * 1024,
.max_path = 4096,
.max_events = std.math.maxInt(u64),
});
defer reader.deinit();
var events: usize = 0;
while (true) {
const next = reader.next() catch return .{ .events = events, .rejected = true };
var lease = next orelse return .{ .events = events, .rejected = false };
lease.deinit();
events += 1;
}
}
fn exists(io: std.Io, dir: std.Io.Dir, path: []const u8) bool {
var file = dir.openFile(io, path, .{}) catch return false;
file.close(io);
return true;
}
fn directoryExists(io: std.Io, dir: std.Io.Dir, path: []const u8) bool {
var child = dir.openDir(io, path, .{}) catch return false;
child.close(io);
return true;
}
/// Records which publication boundaries a run actually reached.
///
/// This answers HOW FAR the transaction got, and nothing else. It cannot answer
/// "was generation 1 open", because `create_store` is checked BEFORE
/// `createRawGeneration` and is the only boundary in the whole prologue: every
/// failure that reaches `importInto` at all observes it, including one that
/// never opened a store. What it does prove is the negative half -- that the
/// fault unwound from the export and not from somewhere downstream with a
/// different abort path.
///
/// Works through either harness: `runFixture` passes hooks through untouched,
/// and `CommandScript.dependencies` chains a caller's observer behind its own
/// rather than replacing it.
const PointRecorder = struct {
seen: std.EnumSet(FailurePoint) = .empty,
fn notify(context: *anyopaque, point: FailurePoint) void {
const self: *PointRecorder = @ptrCast(@alignCast(context));
self.seen.insert(point);
}
/// No `fail_at`: the fault under test is upstream of publication, and this
/// only watches the boundaries the transaction walks on its way out.
fn hooks(self: *PointRecorder) import_cli.PublicationHooks {
return .{ .observer = .{ .context = self, .notify = notify } };
}
};
/// A published destination holds the publish manifest and nothing else.
///
/// The expected set is BORROWED from production rather than retyped: this is
/// the same list `assertPublishManifest` checks staging against before the
/// rename, and a copy here could agree with a stale idea of what a published
/// import contains.
fn expectPublishManifestExactly(io: std.Io, destination: std.Io.Dir) !void {
// A Git import publishes its commit map as well, so it is expected here.
const manifest = import_cli.PublicationBundle.publish_manifest ++ import_cli.PublicationBundle.optional_publish_manifest;
var seen: [manifest.len]bool = @splat(false);
// Reopened for iteration: the caller's handle may lack iterate rights,
// which Linux enforces at readdir time (macOS does not).
var it_dir = try destination.openDir(io, ".", .{ .iterate = true });
defer it_dir.close(io);
var entries = it_dir.iterate();
while (try entries.next(io)) |entry| {
const index = for (manifest, 0..) |name, position| {
if (std.mem.eql(u8, entry.name, name)) break position;
} else {
std.debug.print("unexpected published entry: {s}\n", .{entry.name});
return error.UnexpectedPublishedEntry;
};
if (seen[index]) return error.DuplicatePublishedEntry;
seen[index] = true;
}
for (seen, manifest) |present, name| {
if (!present) {
std.debug.print("missing published entry: {s}\n", .{name});
return error.MissingPublishedEntry;
}
}
}
fn expectNoPublication(io: std.Io, tmp: std.testing.TmpDir, destination: []const u8) !void {
try std.testing.expect(!directoryExists(io, tmp.dir, destination));
try std.testing.expect(!exists(io, tmp.dir, ".zit-import-map.jsonl"));
// Reopened for iteration: the caller's handle may lack iterate rights,
// which Linux enforces at readdir time (macOS does not).
var it_dir = try tmp.dir.openDir(io, ".", .{ .iterate = true });
defer it_dir.close(io);
var entries = it_dir.iterate();
while (try entries.next(io)) |entry| {
try std.testing.expect(!std.mem.startsWith(u8, entry.name, ".zit-import-tmp-"));
}
}
test "import-git --provenance publishes .zit refs and map together only after a complete export" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var git: ScriptedGit = .{ .fast_export = valid_export, .stderr = "Enumerating objects\n" };
const result = runFixtureFull(
tmp,
"fixture.git",
"imported",
false,
&git,
.real_filesystem,
.{ .tree = 5 },
true,
);
try std.testing.expectEqual(cli.Exit.ok, result.code);
try std.testing.expectEqualStrings("imported\n", result.out);
try std.testing.expect(std.mem.indexOf(u8, result.err, "import-git:") != null);
var destination = try tmp.dir.openDir(std.testing.io, "imported", .{});
defer destination.close(std.testing.io);
try std.testing.expect(exists(std.testing.io, destination, ".zit"));
try std.testing.expect(exists(std.testing.io, destination, "refs"));
try std.testing.expect(exists(std.testing.io, destination, ".zit-import-map.jsonl"));
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const imported_checkout = (try checkout.read(std.testing.io, destination, &checkout_buf)).?;
try std.testing.expectEqualStrings("", imported_checkout.version);
// The checkout records `trunk`, not the source's `main`: an import adopts
// this format's name for the main line, which is what every downstream
// consumer that must name it without being told already looks for.
try std.testing.expectEqualStrings("trunk", imported_checkout.branch);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.launched);
try std.testing.expectEqual(@as(usize, 0), result.lifecycle.terminated);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.stderr_drained);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
}
test "import-git finalizes remote refs emitted with all refs" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var git: ScriptedGit = .{ .fast_export = valid_export_with_remote_ref };
const result = runFixtureFull(tmp, "fixture.git", "with-remote", false, &git, .real_filesystem, .{ .tree = 5 }, true);
try std.testing.expectEqual(cli.Exit.ok, result.code);
var destination = try tmp.dir.openDir(std.testing.io, "with-remote", .{});
defer destination.close(std.testing.io);
// One sorted packed file, not a directory tree. Reading the bytes rather
// than probing for paths is the point: the file's whole job is to be
// legible, and its ordering is what makes two imports comparable.
const packed_refs = try destination.readFileAlloc(std.testing.io, "refs", std.testing.allocator, .limited(4096));
defer std.testing.allocator.free(packed_refs);
try std.testing.expectEqualStrings(
import_cli.packed_refs_header ++
"sym-heads/main 104f740458ab03713ed37f67639379c91e4ab3c8992986a2a3483f90f8eb7b19\n" ++
"sym-refs/remotes/origin/main 104f740458ab03713ed37f67639379c91e4ab3c8992986a2a3483f90f8eb7b19\n",
packed_refs,
);
}
test "import-git refuses an existing destination before Git starts" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDir(std.testing.io, "already-there", .default_dir);
var git: ScriptedGit = .{ .fast_export = valid_export };
const result = runFixture(tmp, "fixture.git", "already-there", false, &git, .real_filesystem);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expectEqualStrings("", result.out);
try std.testing.expect(std.mem.indexOf(u8, result.err, "already exists") != null);
try std.testing.expectEqual(@as(usize, 0), result.lifecycle.launched);
try std.testing.expectEqual(@as(usize, 0), result.lifecycle.terminated);
try std.testing.expectEqual(@as(usize, 0), result.lifecycle.stderr_drained);
try std.testing.expectEqual(@as(usize, 0), result.lifecycle.reaped);
}
test "import-git aborts malformed export by terminating draining and reaping once" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var git: ScriptedGit = .{ .fast_export = malformed_export, .stderr = "fatal fixture diagnostic\n" };
const result = runFixture(tmp, "fixture.git", "broken", false, &git, .real_filesystem);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expectEqualStrings("", result.out);
try std.testing.expect(std.mem.indexOf(u8, result.err, "fatal fixture diagnostic") != null);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.launched);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.terminated);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.stderr_drained);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
try expectNoPublication(std.testing.io, tmp, "broken");
}
const FailurePoint = import_cli.PublicationHooks.FailurePoint;
// The publication boundaries this file drives, partitioned by the answer each
// one is owed, and hoisted to file scope so the partition itself can be
// asserted rather than believed.
//
// A `FailurePoint` that appears in no class is a fault arm no test ever runs,
// and nothing about the tag says so: it compiles, it is selectable, and a
// caller who asks for it gets a clean green because `check` never fires it. The
// alias map already produced that shape once -- five of the eight legacy
// selectors were inert and passing. These five names are the only places a new
// boundary can be registered.
//
// What the gate below proves is CLASSIFICATION, not execution: a new
// `FailurePoint` that nobody puts in a class fails it, which is the drift that
// actually happens. It does not prove the three singletons still have tests --
// deleting one leaves its `const` classified and the gate green. Making
// execution observable would need runtime registration, and that is
// order-dependent and breaks under `--test-filter`, so the cure is worse than
// the disease. Deleting a whole test is a visible line in a diff; adding an
// enum member is not.
const create_failure_point: FailurePoint = .create_store;
const post_rename_failure_point: FailurePoint = .after_rename_no_replace;
const parent_sync_failure_point: FailurePoint = .sync_parent_after_publish;
const legacy_failure_points = [_]FailurePoint{
.flush_store, .sync_store,
.close_store, .flush_map,
.sync_map, .close_map,
.sync_temporary_directory, .rename_no_replace,
};
test "import-git removes all temporary output when storage creation fails" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var git: ScriptedGit = .{ .fast_export = valid_export };
const result = runFixture(
tmp,
"fixture.git",
"storage-failed",
false,
&git,
.{ .fail_at = create_failure_point },
);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expectEqualStrings("", result.out);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.launched);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
try expectNoPublication(std.testing.io, tmp, "storage-failed");
}
// Every legacy selector, not a sample of three.
//
// The one-sided selectors are aliases: `check` maps each to its `before_*`
// twin, so a selector whose arm is missing from that map does not fail the
// import at all -- it PUBLISHES, and the caller that asked for a fault reads
// the resulting green as evidence the boundary is safe. Three of these eight
// arms were exercised and five were not, which is the shape where a silently
// inert selector survives. Asserting `Exit.failure` per alias is what makes
// each arm's presence load-bearing.
test "import-git removes all temporary output when publication flush or rename fails" {
for (legacy_failure_points) |failure| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var git: ScriptedGit = .{ .fast_export = valid_export };
const result = runFixture(
tmp,
"fixture.git",
"publication-failed",
false,
&git,
.{ .fail_at = failure },
);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expectEqualStrings("", result.out);
// No child left behind: launched once, reaped once, every alias.
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.launched);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
try expectNoPublication(std.testing.io, tmp, "publication-failed");
}
}
test "import-git writes only a machine-readable destination to stdout and progress to stderr" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var git: ScriptedGit = .{ .fast_export = valid_export, .stderr = "git progress\n" };
const result = runFixture(tmp, "fixture.git", "machine", false, &git, .real_filesystem);
try std.testing.expectEqual(cli.Exit.ok, result.code);
try std.testing.expectEqualStrings("machine\n", result.out);
try std.testing.expect(std.mem.indexOf(u8, result.err, "git progress") != null);
try std.testing.expect(std.mem.indexOf(u8, result.out, "progress") == null);
}
test "import-git --quiet suppresses progress but not diagnostics" {
var quiet_tmp = std.testing.tmpDir(.{});
defer quiet_tmp.cleanup();
var quiet_git: ScriptedGit = .{ .fast_export = valid_export, .stderr = "git progress\n" };
const quiet = runFixture(quiet_tmp, "fixture.git", "quiet", true, &quiet_git, .real_filesystem);
try std.testing.expectEqual(cli.Exit.ok, quiet.code);
try std.testing.expectEqualStrings("quiet\n", quiet.out);
try std.testing.expectEqualStrings("", quiet.err);
var error_tmp = std.testing.tmpDir(.{});
defer error_tmp.cleanup();
var error_git: ScriptedGit = .{ .fast_export = malformed_export, .stderr = "fatal fixture diagnostic\n" };
const failed = runFixture(error_tmp, "fixture.git", "quiet-error", true, &error_git, .real_filesystem);
try std.testing.expectEqual(cli.Exit.failure, failed.code);
try std.testing.expectEqualStrings("", failed.out);
try std.testing.expect(std.mem.indexOf(u8, failed.err, "fatal fixture diagnostic") != null);
}
// The remaining tests specify the Task 10 seam not yet present in
// `git_import_cli`: CommandScript records every preflight/export invocation,
// supplies bounded stdout/stderr streams, and exposes deterministic
// cancellation and publication-boundary faults. Production dispatch constructs
// the OS adapter; tests use no shell or network.
fn scripted(
tmp: std.testing.TmpDir,
script: *import_cli.CommandScript,
destination: []const u8,
quiet: bool,
fault: import_cli.PublicationHooks,
capture: *import_cli.ScriptCapture,
) import_cli.ScriptResult {
return import_cli.runScripted(
std.testing.allocator,
std.testing.io,
.{
.source = "fixture.git",
.destination_parent = tmp.dir,
.destination_name = destination,
.project_code = sync.deriveProjectCode(&.{destination}),
.submanifest = .{ .tree = 5 },
.attribution = .committer,
.default_branch = null,
.quiet = quiet,
},
script,
fault,
capture,
);
}
const ProcessResult = struct {
code: cli.Exit,
out: []const u8,
err: []const u8,
exit_code: u8,
reaped: bool,
reap_calls: usize,
child_gone: bool,
stderr_bytes_drained: usize,
retained_diagnostic_len: usize,
};
/// Drives the real ProductionGit session against the build-wired helper. The
/// only substitution is argv; spawning, pipe adapters, concurrent diagnostic
/// drain, terminate, join, and wait are the production implementation.
fn runProductionPipe(
tmp: std.testing.TmpDir,
destination: []const u8,
mode: []const u8,
) !ProcessResult {
var threaded = std.Io.Threaded.init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var session = import_cli.ProductionGit.initForTest(
std.testing.allocator,
io,
std.testing.environ,
&.{ helper_options.git_import_pipe_helper, mode },
);
const Capture = struct {
var out_storage: [4096]u8 = undefined;
var err_storage: [8192]u8 = undefined;
};
var out = std.Io.Writer.fixed(&Capture.out_storage);
var err = std.Io.Writer.fixed(&Capture.err_storage);
const code = import_cli.run(
std.testing.allocator,
io,
.{
.source = "fixture.git",
.destination_parent = tmp.dir,
.destination_name = destination,
.project_code = sync.deriveProjectCode(&.{destination}),
},
.{ .out = &out, .err = &err },
session.dependencies(.real_filesystem),
);
return .{
.code = code,
.out = out.buffered(),
.err = err.buffered(),
.exit_code = session.exit_code,
.reaped = session.reaped,
.child_gone = session.child == null,
.stderr_bytes_drained = session.stderr_bytes_drained,
.reap_calls = session.reap_calls,
.retained_diagnostic_len = session.stderr_ring_len,
};
}
fn runGit(io: std.Io, argv: []const []const u8) !void {
const result = try std.process.run(std.testing.allocator, io, .{ .argv = argv });
defer std.testing.allocator.free(result.stdout);
defer std.testing.allocator.free(result.stderr);
switch (result.term) {
.exited => |code| if (code == 0) return,
else => {},
}
return error.GitSetupFailed;
}
fn writeFixtureFile(io: std.Io, dir: std.Io.Dir, path: []const u8, bytes: []const u8) !void {
var file = try dir.createFile(io, path, .{ .read = true, .truncate = true });
defer file.close(io);
try file.writeStreamingAll(io, bytes);
}
fn runProductionPreflight(
tmp: std.testing.TmpDir,
source: []const u8,
destination: []const u8,
) cli.Exit {
const Capture = struct {
var out_storage: [1024]u8 = undefined;
var err_storage: [1024]u8 = undefined;
};
var out = std.Io.Writer.fixed(&Capture.out_storage);
var err = std.Io.Writer.fixed(&Capture.err_storage);
var session = import_cli.ProductionGit.init(std.testing.allocator, std.testing.io, std.testing.environ);
defer session.deinit();
return import_cli.run(
std.testing.allocator,
std.testing.io,
.{
.source = source,
.destination_parent = tmp.dir,
.destination_name = destination,
.project_code = sync.deriveProjectCode(&.{destination}),
},
.{ .out = &out, .err = &err },
session.dependencies(.real_filesystem),
);
}
test "import-git parser leaves k unset for analysis and is a real dispatched verb" {
// Absent means "analyze", not "5": the parser must not manufacture a pin
// the runner would then be unable to distinguish from an operator's.
const defaulted = try cli.parseImportGitArgs(&.{ "fixture.git", "dest" });
try std.testing.expectEqual(@as(?u4, null), defaulted.submanifest_k);
try std.testing.expectEqual(cli.Verb.import_git, cli.verbOf("import-git"));
try std.testing.expect(!cli.isPlanned("import-git"));
const parsed = try cli.parseImportGitArgs(&.{
"--submanifest-k", "7",
"--attribution", "author",
"--default-branch", "main",
"--quiet", "fixture.git",
"dest",
});
try std.testing.expectEqual(@as(?u4, 7), parsed.submanifest_k);
try std.testing.expectEqual(.author, parsed.attribution);
try std.testing.expectEqualStrings("main", parsed.default_branch.?);
try std.testing.expect(parsed.quiet);
const min_k = try cli.parseImportGitArgs(&.{ "--submanifest-k", "1", "fixture.git", "dest" });
try std.testing.expectEqual(@as(?u4, 1), min_k.submanifest_k);
const max_k = try cli.parseImportGitArgs(&.{ "--submanifest-k", "12", "fixture.git", "dest" });
try std.testing.expectEqual(@as(?u4, 12), max_k.submanifest_k);
for ([_][]const u8{ "0", "13", "14", "15" }) |value| {
try std.testing.expectError(
cli.ImportGitParseError.InvalidSubmanifestK,
cli.parseImportGitArgs(&.{ "--submanifest-k", value, "fixture.git", "dest" }),
);
}
}
test "import-git parser accepts --into once, anywhere among the options" {
const defaulted = try cli.parseImportGitArgs(&.{ "fixture.git", "dest" });
try std.testing.expect(!defaulted.into);
const leading = try cli.parseImportGitArgs(&.{ "--into", "fixture.git", "dest" });
try std.testing.expect(leading.into);
const trailing = try cli.parseImportGitArgs(&.{ "fixture.git", "dest", "--quiet", "--into" });
try std.testing.expect(trailing.into);
try std.testing.expect(trailing.quiet);
try std.testing.expectError(
cli.ImportGitParseError.RepeatedOption,
cli.parseImportGitArgs(&.{ "--into", "--into", "fixture.git", "dest" }),
);
// After `--` it is an operand, so a third positional is the error.
try std.testing.expectError(
cli.ImportGitParseError.ExtraArgument,
cli.parseImportGitArgs(&.{ "fixture.git", "dest", "--", "--into" }),
);
}
test "import-git parser accepts --no-submanifests and refuses it paired with a pinned k" {
const defaulted = try cli.parseImportGitArgs(&.{ "fixture.git", "dest" });
try std.testing.expect(!defaulted.no_submanifests);
// The flag declines the mechanism; it does not manufacture a k, and it must
// not be confusable with one -- `submanifest_k` stays absent.
const flat = try cli.parseImportGitArgs(&.{ "--no-submanifests", "fixture.git", "dest" });
try std.testing.expect(flat.no_submanifests);
try std.testing.expectEqual(@as(?u4, null), flat.submanifest_k);
// BOTH orders. An in-arm check would only fire for whichever token happened
// to be seen second, which is exactly the bug this pairing invites.
try std.testing.expectError(
cli.ImportGitParseError.ConflictingSubmanifestOptions,
cli.parseImportGitArgs(&.{ "--no-submanifests", "--submanifest-k", "5", "fixture.git", "dest" }),
);
try std.testing.expectError(
cli.ImportGitParseError.ConflictingSubmanifestOptions,
cli.parseImportGitArgs(&.{ "--submanifest-k", "5", "--no-submanifests", "fixture.git", "dest" }),
);
// A malformed token still reports its own cause: the conflict is ranked
// below `first_err`, so an invalid k is not masked by the pairing.
try std.testing.expectError(
cli.ImportGitParseError.InvalidSubmanifestK,
cli.parseImportGitArgs(&.{ "--submanifest-k", "0", "--no-submanifests", "fixture.git", "dest" }),
);
// Same shape as every other boolean flag on this verb.
try std.testing.expectError(
cli.ImportGitParseError.RepeatedOption,
cli.parseImportGitArgs(&.{ "--no-submanifests", "--no-submanifests", "fixture.git", "dest" }),
);
// Help wins over the conflict, so `--help` stays informative no matter what
// else is on the line.
const helped = try cli.parseImportGitArgs(&.{ "--no-submanifests", "--submanifest-k", "5", "--help" });
try std.testing.expect(helped.help);
// It composes with every other behavioral flag; none of them interact.
const composed = try cli.parseImportGitArgs(&.{
"--no-submanifests", "--refuse-signed",
"--attribution", "author",
"--default-branch", "trunk",
"--quiet", "fixture.git",
"dest",
});
try std.testing.expect(composed.no_submanifests);
try std.testing.expect(composed.refuse_signed);
try std.testing.expectEqual(.author, composed.attribution);
try std.testing.expectEqualStrings("trunk", composed.default_branch.?);
try std.testing.expect(composed.quiet);
}
test "import-git parser exposes the export payload budget as an operator flag" {
// Every ceiling an honest source can reach must be raisable without a
// recompile. This one budget carries both the cumulative payload limit and
// the derived per-commit file-command ceiling, so it is the only knob the
// verb needs.
const defaulted = try cli.parseImportGitArgs(&.{ "fixture.git", "dest" });
try std.testing.expectEqual(@as(?u64, null), defaulted.max_export_payload_bytes);
const raised = try cli.parseImportGitArgs(&.{
"--max-export-payload-bytes", "68719476736",
"fixture.git", "dest",
});
try std.testing.expectEqual(@as(?u64, 68719476736), raised.max_export_payload_bytes);
// Zero derives a per-commit ceiling of zero: a budget no import can ever
// satisfy is a typo, not a policy.
try std.testing.expectError(
cli.ImportGitParseError.InvalidExportPayloadBytes,
cli.parseImportGitArgs(&.{ "--max-export-payload-bytes", "0", "fixture.git", "dest" }),
);
try std.testing.expectError(
cli.ImportGitParseError.InvalidExportPayloadBytes,
cli.parseImportGitArgs(&.{ "--max-export-payload-bytes", "many", "fixture.git", "dest" }),
);
try std.testing.expectError(
cli.ImportGitParseError.MissingOptionValue,
cli.parseImportGitArgs(&.{ "fixture.git", "dest", "--max-export-payload-bytes" }),
);
try std.testing.expectError(
cli.ImportGitParseError.RepeatedOption,
cli.parseImportGitArgs(&.{
"--max-export-payload-bytes", "1024",
"--max-export-payload-bytes", "2048",
"fixture.git", "dest",
}),
);
}
/// Whether `token` appears anywhere in `argv`. Positional indices would encode
/// the argv's shape, which is not what this asserts.
fn argvHas(argv: []const []const u8, token: []const u8) bool {
for (argv) |candidate| {
if (std.mem.eql(u8, candidate, token)) return true;
}
return false;
}
test "import-git strips signatures by default and aborts only under --refuse-signed" {
// Fossil has no signature card, so refusing by default withheld the import
// without saving anything. The default must therefore strip.
const defaulted = try cli.parseImportGitArgs(&.{ "fixture.git", "dest" });
try std.testing.expect(!defaulted.refuse_signed);
const stripping = import_cli.exportArgv("fixture.git", defaulted.refuse_signed);
try std.testing.expect(argvHas(&stripping, "--signed-commits=strip"));
try std.testing.expect(argvHas(&stripping, "--signed-tags=strip"));
try std.testing.expect(!argvHas(&stripping, "--signed-commits=abort"));
try std.testing.expect(!argvHas(&stripping, "--signed-tags=abort"));
// The opt-out restores the refusal, and nothing else about the argv moves.
const opted_out = try cli.parseImportGitArgs(&.{ "--refuse-signed", "fixture.git", "dest" });
try std.testing.expect(opted_out.refuse_signed);
const aborting = import_cli.exportArgv("fixture.git", opted_out.refuse_signed);
try std.testing.expect(argvHas(&aborting, "--signed-commits=abort"));
try std.testing.expect(argvHas(&aborting, "--signed-tags=abort"));
try std.testing.expectEqual(stripping.len, aborting.len);
for (stripping, aborting) |strip_token, abort_token| {
if (std.mem.startsWith(u8, strip_token, "--signed-")) continue;
try std.testing.expectEqualStrings(strip_token, abort_token);
}
// Same error shape as every other boolean flag: repeated is a usage error,
// and the retired spelling is simply unknown.
try std.testing.expectError(
cli.ImportGitParseError.RepeatedOption,
cli.parseImportGitArgs(&.{ "--refuse-signed", "--refuse-signed", "fixture.git", "dest" }),
);
try std.testing.expectError(
cli.ImportGitParseError.UnknownOption,
cli.parseImportGitArgs(&.{ "--drop-signatures", "fixture.git", "dest" }),
);
}
test "an import that adds to a destination excludes every known tip and keeps excluded parents referenced" {
const gpa = std.testing.allocator;
const tips = [_][]const u8{
"a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
"b5d4045c3f466fa91fe2cc6abe79232a1a57cdf1",
};
const argv = try import_cli.exportArgvInto(gpa, "fixture.git", true, &tips);
defer import_cli.freeExportArgvInto(gpa, argv);
const base = import_cli.exportArgv("fixture.git", true);
try std.testing.expectEqual(base.len + 2 + tips.len, argv.len);
for (base, argv[0..base.len]) |expected, actual| try std.testing.expectEqualStrings(expected, actual);
try std.testing.expectEqualStrings("--reference-excluded-parents", argv[base.len]);
try std.testing.expectEqualStrings("--tag-of-filtered-object=drop", argv[base.len + 1]);
try std.testing.expectEqualStrings("^a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", argv[base.len + 2]);
try std.testing.expectEqualStrings("^b5d4045c3f466fa91fe2cc6abe79232a1a57cdf1", argv[base.len + 3]);
// No known tips is still a valid incremental export: it excludes nothing
// and so writes what a fresh import would, through the same parent rule.
const bare = try import_cli.exportArgvInto(gpa, "fixture.git", false, &.{});
defer import_cli.freeExportArgvInto(gpa, bare);
try std.testing.expectEqual(base.len + 2, bare.len);
}
test "import-git help exits zero and malformed argument forms exit usage without process launch" {
const help = import_cli.dispatchForTest(&.{"--help"});
try std.testing.expectEqual(cli.Exit.ok, help.code);
try std.testing.expectEqualStrings("", help.err);
try std.testing.expect(std.mem.startsWith(u8, help.out, "usage: zit import-git SOURCE DEST"));
// Substring checks are what let the previous hand transcription rot two
// options behind the real help, so pin every option the usage line owns.
for ([_][]const u8{
"--submanifest-k K | --no-submanifests",
"--attribution",
"--default-branch",
"--into",
"--refuse-signed",
"--provenance",
"--max-export-payload-bytes",
"--quiet",
}) |option| {
try std.testing.expect(std.mem.indexOf(u8, help.out, option) != null);
}
const accepted_flat = import_cli.dispatchForTest(&.{ "--no-submanifests", "fixture.git", "dest" });
try std.testing.expectEqual(cli.Exit.failure, accepted_flat.code);
try std.testing.expectEqual(@as(usize, 0), accepted_flat.lifecycle.launched);
try std.testing.expectEqualStrings("test dispatch requires scripted dependencies\n", accepted_flat.err);
const accepted_max = import_cli.dispatchForTest(&.{ "--submanifest-k", "12", "fixture.git", "dest" });
try std.testing.expectEqual(cli.Exit.failure, accepted_max.code);
try std.testing.expectEqual(@as(usize, 0), accepted_max.lifecycle.launched);
try std.testing.expectEqualStrings("test dispatch requires scripted dependencies\n", accepted_max.err);
for ([_][]const []const u8{
&.{ "--submanifest-k", "0", "fixture.git", "dest" },
&.{ "--submanifest-k", "13", "fixture.git", "dest" },
&.{ "--submanifest-k", "14", "fixture.git", "dest" },
&.{ "--submanifest-k", "15", "fixture.git", "dest" },
&.{ "--attribution", "committerish", "fixture.git", "dest" },
// The conflicting pair exits `usage` in both orders and launches
// nothing -- no process is spawned to discover an argument mistake.
&.{ "--no-submanifests", "--submanifest-k", "5", "fixture.git", "dest" },
&.{ "--submanifest-k", "5", "--no-submanifests", "fixture.git", "dest" },
&.{ "--no-submanifests", "--no-submanifests", "fixture.git", "dest" },
&.{"fixture.git"},
&.{ "fixture.git", "dest", "extra" },
}) |args| {
const usage = import_cli.dispatchForTest(args);
try std.testing.expectEqual(cli.Exit.usage, usage.code);
try std.testing.expectEqual(@as(usize, 0), usage.lifecycle.launched);
try std.testing.expectEqualStrings("", usage.out);
try std.testing.expect(usage.err.len != 0);
}
}
test "import-git rejects missing Git non-repository shallow and partial sources before export" {
const preflight_failures = [_]import_cli.CommandScript.Preflight{
.git_missing,
.not_repository,
.missing_objects,
.shallow,
.partial_promisor,
};
for (preflight_failures) |preflight| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{ .preflight = preflight });
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "rejected", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expectEqual(@as(usize, 0), script.export_starts);
try std.testing.expect(script.preflight_starts != 0);
try std.testing.expectEqual(@as(usize, 0), result.lifecycle.terminated);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
try expectNoPublication(std.testing.io, tmp, "rejected");
}
}
test "import-git production preflight rejects absent non-repository missing-object shallow and partial sources" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var root_buf: [128]u8 = undefined;
const root = try std.fmt.bufPrint(&root_buf, ".zig-cache/tmp/{s}", .{tmp.sub_path});
var source_buf: [160]u8 = undefined;
const source = try std.fmt.bufPrint(&source_buf, "{s}/source", .{root});
// A non-existent -C path and an existing non-repository both execute real
// ProductionGit query children and fail before an export can launch.
try std.testing.expectEqual(cli.Exit.failure, runProductionPreflight(tmp, source, "missing-git"));
try tmp.dir.createDir(std.testing.io, "not-repository", .default_dir);
var nonrepo_buf: [160]u8 = undefined;
const nonrepo = try std.fmt.bufPrint(&nonrepo_buf, "{s}/not-repository", .{root});
try std.testing.expectEqual(cli.Exit.failure, runProductionPreflight(tmp, nonrepo, "not-repository-dest"));
try expectNoPublication(std.testing.io, tmp, "missing-git");
try expectNoPublication(std.testing.io, tmp, "not-repository-dest");
try runGit(std.testing.io, &.{ "git", "init", source });
try runGit(std.testing.io, &.{ "git", "-C", source, "config", "user.name", "Zit Test" });
try runGit(std.testing.io, &.{ "git", "-C", source, "config", "user.email", "[email protected]" });
try runGit(std.testing.io, &.{ "git", "-C", source, "commit", "--allow-empty", "-m", "init" });
try runGit(std.testing.io, &.{ "git", "-C", source, "config", "extensions.partialClone", "origin" });
try std.testing.expectEqual(cli.Exit.failure, runProductionPreflight(tmp, source, "partial"));
try runGit(std.testing.io, &.{ "git", "-C", source, "config", "--unset", "extensions.partialClone" });
try writeFixtureFile(std.testing.io, tmp.dir, "source/.git/shallow", "0000000000000000000000000000000000000000\n");
try std.testing.expectEqual(cli.Exit.failure, runProductionPreflight(tmp, source, "shallow"));
try tmp.dir.deleteFile(std.testing.io, "source/.git/shallow");
try tmp.dir.deleteTree(std.testing.io, "source/.git/objects");
try std.testing.expectEqual(cli.Exit.failure, runProductionPreflight(tmp, source, "missing-objects"));
try expectNoPublication(std.testing.io, tmp, "partial");
try expectNoPublication(std.testing.io, tmp, "shallow");
try expectNoPublication(std.testing.io, tmp, "missing-objects");
}
test "import-git records detached HEAD and refuses it unless default branch is explicit" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{ .preflight = .detached_head, .fast_export = valid_export });
var rejected_cap: import_cli.ScriptCapture = .{};
const rejected = scripted(tmp, &script, "detached", false, .real_filesystem, &rejected_cap);
try std.testing.expectEqual(cli.Exit.failure, rejected.code);
try std.testing.expectEqual(@as(usize, 0), script.export_starts);
try expectNoPublication(std.testing.io, tmp, "detached");
script.default_branch = "main";
var accepted_cap: import_cli.ScriptCapture = .{};
const accepted = scripted(tmp, &script, "detached-with-branch", false, .real_filesystem, &accepted_cap);
try std.testing.expectEqual(cli.Exit.ok, accepted.code);
try std.testing.expect(script.detached_head_recorded);
}
test "import-git starts fast-export with the exact safe argv and disabled prompt and lazy fetch" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = valid_export });
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "argv", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.ok, result.code);
try std.testing.expectEqualStrings(
"git -C fixture.git fast-export --all --show-original-ids --mark-tags --signed-commits=strip --signed-tags=strip --use-done-feature",
script.exportCommandLine(),
);
try std.testing.expectEqualStrings("0", script.environment.get("GIT_TERMINAL_PROMPT").?);
try std.testing.expectEqualStrings("1", script.environment.get("GIT_NO_LAZY_FETCH").?);
}
test "import-git drains real OS-pipe backpressure concurrently while stdout remains active" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const result = try runProductionPipe(tmp, "flood", "success");
try std.testing.expectEqual(cli.Exit.ok, result.code);
try std.testing.expectEqual(@as(usize, 8 * 1024 * 1024), result.stderr_bytes_drained);
try std.testing.expectEqual(@as(usize, 4096), result.retained_diagnostic_len);
// Strictly smaller than the capture: a full buffer means the completion
// line asserted below may have been silently dropped, not that output was bounded.
try std.testing.expect(result.err.len < 8192);
try std.testing.expect(std.mem.indexOf(u8, result.err, "imported 'fixture.git'") != null);
try std.testing.expectEqual(@as(u8, 0), result.exit_code);
try std.testing.expect(result.reaped);
try std.testing.expect(result.child_gone);
}
test "import-git cancellation terminates a real child then joins and reaps its actual status once" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const result = try runProductionPipe(tmp, "cancelled", "hold");
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expect(result.exit_code != 0);
try std.testing.expect(result.err.len < 4096);
try std.testing.expect(std.mem.indexOf(u8, result.err, "Malformed") != null);
try std.testing.expect(std.mem.indexOf(u8, result.err, "GitExportFailed") == null);
try std.testing.expect(result.reaped);
try std.testing.expect(result.child_gone);
try expectNoPublication(std.testing.io, tmp, "cancelled");
}
test "import-git non-Darwin/Linux fallback drains stable stderr ownership then reaps once" {
if (builtin.os.tag == .macos or builtin.os.tag == .linux) return;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const result = try runProductionPipe(tmp, "fallback-cancelled", "hold");
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expect(result.stderr_bytes_drained != 0);
try std.testing.expect(result.reaped);
try std.testing.expectEqual(@as(usize, 1), result.reap_calls);
try std.testing.expect(result.child_gone);
try expectNoPublication(std.testing.io, tmp, "fallback-cancelled");
}
test "import-git rejects a nonzero completed export after draining and reaping it once" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const result = try runProductionPipe(tmp, "nonzero-export", "nonzero");
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expectEqual(@as(u8, 23), result.exit_code);
try std.testing.expect(result.reaped);
try std.testing.expect(result.child_gone);
try expectNoPublication(std.testing.io, tmp, "nonzero-export");
}
test "import-git prefers a nonzero truncated export status over its parser EOF" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const result = try runProductionPipe(tmp, "signed-like-export", "truncated-nonzero");
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expectEqual(@as(u8, 23), result.exit_code);
try std.testing.expect(std.mem.indexOf(u8, result.err, "GitExportFailed") != null);
try std.testing.expect(std.mem.indexOf(u8, result.err, "fatal: signed commit rejected") != null);
try std.testing.expect(std.mem.indexOf(u8, result.err, "UnexpectedEof") == null);
try std.testing.expect(result.reaped);
try std.testing.expect(result.child_gone);
try expectNoPublication(std.testing.io, tmp, "signed-like-export");
}
test "import-git verifies the frozen .zit and import map before the atomic rename" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = valid_export });
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "verified", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.ok, result.code);
try std.testing.expect(script.publication.store_reopened_for_verify);
try std.testing.expect(script.publication.map_reopened_for_verify);
try std.testing.expect(script.publication.verify_before_publish);
}
test "import-git competing destination at commit point never clobbers it and removes its temporary sibling" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{
.preflight = .branch_head,
.fast_export = valid_export,
.competing_destination_at_publish = true,
});
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "raced", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expect(script.competitor_destination_intact);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
try std.testing.expect(!script.temporary_sibling_left);
}
test "import-git never publishes or deletes a replaced staging sibling" {
const replacements = [_]import_cli.CommandScript.StagingReplacement{ .directory, .symlink };
for (replacements) |replacement| {
for (0..8) |_| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDir(std.testing.io, "outside", .default_dir);
try writeFixtureFile(std.testing.io, tmp.dir, "outside/replacement-sentinel", "survives\n");
var script = import_cli.CommandScript.init(.{
.preflight = .branch_head,
.fast_export = valid_export,
.replace_staging_at_publish = replacement,
});
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "raced-staging", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expect(std.mem.indexOf(u8, result.err, "StagingEntryReplaced") != null);
try std.testing.expect(script.staging_replacement_observed);
try std.testing.expectEqual(@as(usize, 0), result.lifecycle.terminated);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.stderr_drained);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
try std.testing.expect(!directoryExists(std.testing.io, tmp.dir, "raced-staging"));
try std.testing.expect(!exists(std.testing.io, tmp.dir, ".zit-import-map.jsonl"));
const staging_name = script.stagingReplacementName();
try std.testing.expect(staging_name.len != 0);
var outside = try tmp.dir.openDir(std.testing.io, "outside", .{ .follow_symlinks = false });
defer outside.close(std.testing.io);
try std.testing.expect(exists(std.testing.io, outside, "replacement-sentinel"));
switch (replacement) {
.directory => {
var preserved = try tmp.dir.openDir(std.testing.io, staging_name, .{ .follow_symlinks = false });
defer preserved.close(std.testing.io);
try std.testing.expect(exists(std.testing.io, preserved, "replacement-sentinel"));
},
.symlink => {
var target: [32]u8 = undefined;
const len = try tmp.dir.readLink(std.testing.io, staging_name, &target);
try std.testing.expectEqualStrings("outside", target[0..len]);
},
}
}
}
}
test "import-git default ceilings clear the retired 200k per-commit file wall" {
const limits: import_cli.ImportLimits = .{};
// The import leaves the arena budget at its default, so that default is
// what an operator actually meets.
const defaults: fast_export.Limits = .{ .max_line = 0, .max_data = 0, .max_path = 0, .max_events = std.math.maxInt(u64) };
const ceiling = fast_export.fileCommandCeiling(defaults);
// The retired ceiling was 200,000 because that is the benchmark corpus
// width. The derived one must clear it by orders of magnitude, so no
// ordinary monorepo root commit can reach it.
try std.testing.expect(ceiling > 100 * 200_000);
try std.testing.expectEqual(
defaults.max_command_arena_bytes / fast_export.min_retained_bytes_per_file_command,
ceiling,
);
// The cumulative payload budget must clear a real repository: sqlite's
// export stream is 12.2 GiB, and a default that refuses it is a default
// that refuses ordinary work. It must also no longer move the ceiling.
try std.testing.expect(limits.max_export_payload_bytes > 16 * @as(u64, 1024 * 1024 * 1024));
var raised = defaults;
raised.max_total_data = limits.max_export_payload_bytes;
try std.testing.expectEqual(ceiling, fast_export.fileCommandCeiling(raised));
try std.testing.expect(limits.max_retained_bytes >= 64 * 1024 * 1024);
}
test "import-git reader limits reject before event and data parsing allocations" {
const event_limited = "feature done\nblob\nmark :1\ndata 1\nx\n";
var event_reader = fast_export.Reader.init(std.testing.allocator, .fixed(event_limited), .{
.max_line = 1024,
.max_data = 1024,
.max_path = 1024,
.max_events = 0,
.max_total_data = 1024,
});
defer event_reader.deinit();
try std.testing.expectError(error.EventLimitExceeded, event_reader.next());
const data_limited = "feature done\nblob\nmark :1\ndata 1\nx\n";
var data_reader = fast_export.Reader.init(std.testing.allocator, .fixed(data_limited), .{
.max_line = 1024,
.max_data = 1024,
.max_path = 1024,
.max_events = 1,
.max_total_data = 0,
});
defer data_reader.deinit();
try std.testing.expectError(error.DataLimitExceeded, data_reader.next());
}
test "import-git enforces total import ceilings before publication" {
// The two budgets are separate now, so the accepted value only has to
// clear the stream's nine payload bytes.
const one_command_budget = fast_export.min_retained_bytes_per_file_command;
const accepted_limits: import_cli.ImportLimits = .{
.max_export_payload_bytes = one_command_budget,
.max_retained_bytes = 1024,
};
const RejectedLimit = struct {
limits: import_cli.ImportLimits,
error_name: []const u8,
};
const rejected_limits = [_]RejectedLimit{
.{
// One byte below the stream's own payload. The blob's five bytes
// admit; the commit message's four push the total past it, before
// any file command is parsed.
.limits = .{
.max_export_payload_bytes = 8,
},
.error_name = "ExportPayloadLimitExceeded",
},
.{
.limits = .{
.max_export_payload_bytes = accepted_limits.max_export_payload_bytes,
.max_retained_bytes = 0,
},
.error_name = "ImporterRetainedByteLimitExceeded",
},
};
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{
.preflight = .branch_head,
.fast_export = valid_export_with_oids,
.limits = accepted_limits,
});
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "at-limit", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.ok, result.code);
try std.testing.expectEqual(@as(usize, 0), result.lifecycle.terminated);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.stderr_drained);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
}
for (rejected_limits, 0..) |rejected, index| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{
.preflight = .branch_head,
.fast_export = valid_export_with_oids,
.limits = rejected.limits,
});
var destination: [32]u8 = undefined;
const destination_name = try std.fmt.bufPrint(&destination, "limit-{d}", .{index});
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, destination_name, false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expect(std.mem.indexOf(u8, result.err, rejected.error_name) != null);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.terminated);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.stderr_drained);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
try expectNoPublication(std.testing.io, tmp, destination_name);
}
{
// One byte below one command's minimum retention derives a ceiling of
// zero, so the stream's single `M` command is refused. The failure must
// name the observed count, the ceiling, and the derivation. It must
// NOT name a flag: this ceiling is structural, and the payload knob
// that once raised it no longer moves it at all.
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{
.preflight = .branch_head,
.fast_export = valid_export_with_oids,
.limits = .{
.max_command_arena_bytes = one_command_budget - 1,
.max_retained_bytes = accepted_limits.max_retained_bytes,
},
});
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "command-ceiling", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
var expected: [512]u8 = undefined;
const diagnostic = try std.fmt.bufPrint(
&expected,
"import-git: import failed: ExportFileCommandLimitExceeded\n" ++
"import-git: a commit exceeded the per-commit file-command ceiling: " ++
"observed at least 1, ceiling 0, derived from the {d}-byte per-commit " ++
"arena budget at {d} minimum retained bytes per command. This is a " ++
"structural bound on one commit, not a total: no option raises it, " ++
"because a commit at this width is beyond what the importer can hold whole\n",
.{ one_command_budget - 1, one_command_budget },
);
try std.testing.expect(std.mem.indexOf(u8, result.err, diagnostic) != null);
try std.testing.expect(std.mem.indexOf(u8, result.err, "--max-export-payload-bytes") == null);
try expectNoPublication(std.testing.io, tmp, "command-ceiling");
}
{
// The cumulative-payload refusal must name its numbers for the same
// reason the file-command one does. `ExportPayloadLimitExceeded` alone
// sent a real 32k-commit import 17 minutes downstream to learn a single
// integer, and the integer it needs is not even the one it was refused
// at: parsing stops at the overrun, so the observed total is a lower
// bound on the stream's payload and the budget must clear the whole of
// it. Saying "above 9" would name a value that fails again.
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{
.preflight = .branch_head,
.fast_export = valid_export_with_oids,
.limits = .{
.max_export_payload_bytes = 8,
.max_retained_bytes = accepted_limits.max_retained_bytes,
},
});
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "payload-ceiling", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expect(std.mem.indexOf(
u8,
result.err,
"import-git: import failed: ExportPayloadLimitExceeded\n" ++
"import-git: the export stream's cumulative payload exceeded its budget: " ++
"observed at least 9 bytes against a budget of 8 bytes; parsing stopped there, " ++
"so the stream's full payload is larger than the observed total; re-run with " ++
"--max-export-payload-bytes above the stream's full payload\n",
) != null);
try expectNoPublication(std.testing.io, tmp, "payload-ceiling");
}
}
const pre_rename_failure_points = [_]FailurePoint{
.before_flush_store, .after_flush_store,
.before_sync_store, .after_sync_store,
.before_close_store, .after_close_store,
.before_flush_map, .after_flush_map,
.before_sync_map, .after_sync_map,
.before_close_map, .after_close_map,
.capture_drop_set, .create_packed_store,
.before_pack, .after_pack,
.before_sync_packed_store, .after_sync_packed_store,
.before_close_packed_store, .after_close_packed_store,
.verify_raw_store, .verify_store,
.verify_map, .before_discard_raw_generation,
.after_discard_raw_generation, .before_sync_temporary_directory,
.after_sync_temporary_directory, .before_rename_no_replace,
};
test "import-git aborts every pre-rename writable-boundary failure without a partial destination" {
for (pre_rename_failure_points) |failure| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = valid_export });
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "boundary-failed", false, .{ .fail_at = failure }, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
// All three clauses, per case. A pre-rename abort that only proves DEST
// is absent is compatible with a retained staging sibling holding the
// whole raw generation -- on a real corpus that is tens of megabytes of
// garbage per failed import, and it is invisible to a destination-only
// assertion.
//
// No child process.
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.launched);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
// No destination.
try expectNoPublication(std.testing.io, tmp, "boundary-failed");
// No temporary sibling -- asked a second time, fail-CLOSED, from
// inside the harness. `expectNoPublication` scans `tmp.dir` from the
// outside; this one is taken by `runScripted` the instant `run`
// returns, against the parent the transaction was actually handed, and
// it reports a LEAK whenever it cannot look -- no parent, no `io`, an
// iterate error. A negative assertion answered "clean" on a question
// that could not be asked is exactly how this clause would go vacuous.
try std.testing.expect(!script.temporary_sibling_left);
}
}
// The gate on the four fault sweeps above: every boundary the transaction
// declares is driven by one of them, and each is driven by exactly one.
//
// This is what makes "failures at EVERY new boundary" checkable instead of
// asserted. Without it, adding a `FailurePoint` -- which is what every new
// writable step in this transaction does -- silently ships an untested fault
// arm, and the suite stays green because the arm is never selected. Both
// directions are asserted for the same reason `ci_test.zig` asserts its step
// set both ways: a class list that names a point twice is a bookkeeping error
// that would otherwise let a real point go missing while the count still
// matched.
const into_failure_points = [_]FailurePoint{ .before_into_commit, .before_into_map };
/// What the scripted source publishes once `added_export_with_oids` landed.
const added_source_refs = [_]import_cli.SourceRef{.{ .name = @constCast("refs/heads/main"), .oid = @constCast("3333333333333333333333333333333333333333") }};
fn scriptedInto(
tmp: std.testing.TmpDir,
script: *import_cli.CommandScript,
destination: []const u8,
fault: import_cli.PublicationHooks,
capture: *import_cli.ScriptCapture,
) import_cli.ScriptResult {
return import_cli.runScripted(
std.testing.allocator,
std.testing.io,
.{
.source = "fixture.git",
.destination_parent = tmp.dir,
.destination_name = destination,
.project_code = sync.deriveProjectCode(&.{destination}),
.submanifest = .{ .tree = null },
.attribution = .committer,
.default_branch = null,
.quiet = false,
.into = true,
},
script,
fault,
capture,
);
}
fn destinationStoreSize(tmp: std.testing.TmpDir, destination: []const u8) !u64 {
const io = std.testing.io;
var dir = try tmp.dir.openDir(io, destination, .{});
defer dir.close(io);
var file = try dir.openFile(io, ".zit", .{ .mode = .read_only });
defer file.close(io);
return (try file.stat(io)).size;
}
test "import-git --into adds one commit on top of a scripted import" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var first = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = valid_export_with_oids });
var first_cap: import_cli.ScriptCapture = .{};
const imported = scripted(tmp, &first, "dest", true, .real_filesystem, &first_cap);
try std.testing.expectEqual(cli.Exit.ok, imported.code);
var adding = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = added_export_with_oids });
adding.source_refs = &added_source_refs;
var adding_cap: import_cli.ScriptCapture = .{};
const added = scriptedInto(tmp, &adding, "dest", .real_filesystem, &adding_cap);
try std.testing.expectEqual(cli.Exit.ok, added.code);
var dest = try tmp.dir.openDir(io, "dest", .{});
defer dest.close(io);
var map = try @import("zit").git_import_map.load(gpa, io, dest);
defer map.deinit();
try std.testing.expectEqual(@as(usize, 2), map.contents.commits.len);
}
test "import-git --into appends to the destination, and a failure before the map leaves it as it was" {
const gpa = std.testing.allocator;
const io = std.testing.io;
for (into_failure_points) |failure| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var first = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = valid_export_with_oids });
var first_cap: import_cli.ScriptCapture = .{};
const imported = scripted(tmp, &first, "dest", true, .real_filesystem, &first_cap);
try std.testing.expectEqual(cli.Exit.ok, imported.code);
const size_before = try destinationStoreSize(tmp, "dest");
var dest = try tmp.dir.openDir(io, "dest", .{});
defer dest.close(io);
const map_before = try dest.readFileAlloc(io, ".zit-import-map", gpa, .limited(1 << 20));
defer gpa.free(map_before);
var failing = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = added_export_with_oids });
failing.source_refs = &added_source_refs;
var failing_cap: import_cli.ScriptCapture = .{};
const failed = scriptedInto(tmp, &failing, "dest", .{ .fail_at = failure }, &failing_cap);
try std.testing.expectEqual(cli.Exit.failure, failed.code);
try std.testing.expectEqual(@as(?usize, 1), failing.known_tips_seen);
try std.testing.expect(std.mem.indexOf(u8, failed.err, "the destination is unchanged") != null);
try std.testing.expectEqual(size_before, try destinationStoreSize(tmp, "dest"));
const map_after_failure = try dest.readFileAlloc(io, ".zit-import-map", gpa, .limited(1 << 20));
defer gpa.free(map_after_failure);
try std.testing.expectEqualSlices(u8, map_before, map_after_failure);
var adding = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = added_export_with_oids });
adding.source_refs = &added_source_refs;
var adding_cap: import_cli.ScriptCapture = .{};
const added = scriptedInto(tmp, &adding, "dest", .real_filesystem, &adding_cap);
try std.testing.expectEqual(cli.Exit.ok, added.code);
try std.testing.expect(std.mem.indexOf(u8, added.err, "added 1 check-in(s)") != null);
try std.testing.expect(try destinationStoreSize(tmp, "dest") > size_before);
var map = try @import("zit").git_import_map.load(gpa, io, dest);
defer map.deinit();
try std.testing.expectEqual(@as(usize, 2), map.contents.commits.len);
const new_commit = map.lookupOid("3333333333333333333333333333333333333333") orelse return error.NewCommitUnmapped;
try std.testing.expectEqualStrings("trunk", new_commit.branch);
try std.testing.expectEqual(@as(u64, 1), new_commit.depth);
try std.testing.expectEqualStrings(new_commit.oid, map.lookupRef("refs/heads/main").?.oid);
}
}
test "import-git drives every declared publication failure point in exactly one fault class" {
var covered = std.EnumSet(FailurePoint).empty;
const singletons = [_]FailurePoint{
create_failure_point,
post_rename_failure_point,
parent_sync_failure_point,
};
for (&[_][]const FailurePoint{
&singletons,
&legacy_failure_points,
&pre_rename_failure_points,
&into_failure_points,
}) |class| {
for (class) |point| {
if (covered.contains(point)) {
std.debug.print("failure point classified twice: {t}\n", .{point});
return error.DuplicateFailurePointClass;
}
covered.insert(point);
}
}
const info = @typeInfo(FailurePoint).@"enum";
inline for (info.field_names, info.field_values) |name, value| {
const point: FailurePoint = @fromBackingInt(@intCast(value));
if (!covered.contains(point)) {
std.debug.print("failure point never driven by any test: {s}\n", .{name});
return error.UndrivenFailurePoint;
}
}
try std.testing.expectEqual(@typeInfo(FailurePoint).@"enum".field_names.len, covered.count());
}
// The rename is the commit point, and `after_rename_no_replace` is the first
// boundary strictly past it. Every other publication fault is answered with
// "DEST is absent"; this one must be answered with the opposite, because by the
// time it fires the verified generation is already the destination and undoing
// it would destroy a complete repository to report a failure about a step that
// happened afterwards.
//
// The staging sibling is gone here for the same reason -- it WAS the
// destination -- so `expectNoPublication`'s sibling clause is asserted directly
// rather than through a helper whose first clause would be inverted.
test "import-git keeps the published destination when a boundary strictly after the rename fails" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = valid_export });
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "post-rename", false, .{ .fail_at = post_rename_failure_point }, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.launched);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
try std.testing.expect(!script.temporary_sibling_left);
var destination = try tmp.dir.openDir(std.testing.io, "post-rename", .{});
defer destination.close(std.testing.io);
// The whole manifest and NOTHING BESIDE IT. Naming only the entries that
// must be present would pass a destination that also carried a fifth --
// and the rename ships the staging directory whole, so anything extra in
// it is published by accident into every imported repository.
try expectPublishManifestExactly(std.testing.io, destination);
// Generation 1 left staging before the rename, so it cannot have ridden
// into the published destination on the way past this boundary.
try std.testing.expect(!exists(std.testing.io, destination, ".zit-import-raw"));
}
// Cancellation and child failure WHILE GENERATION 1 EXISTS.
//
// Both faults land after `createRawGeneration` and before anything packs, which
// is the one window in which the transaction owns a live, half-written store
// that no later stage will ever look at. Neither seam had a test: `run` fails
// out of `consumeExport`, and the only thing that then removes generation 1 is
// the bundle's own abort path.
test "import-git unwinds a cancelled export that already opened the raw generation" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// `cancel_export_mid_stream` replaces the stream with one that parses a blob
// and then hits a record the reader rejects -- a truncation, not a rejection
// at record zero, so the raw generation is open when it happens.
var script = import_cli.CommandScript.init(.{
.preflight = .branch_head,
.fast_export = valid_export,
.cancel_export_mid_stream = true,
});
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "cancelled", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
// The child is terminated and reaped exactly once: an abandoned export is
// still a process, and leaking it would outlive the failed import.
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.launched);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.terminated);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
try expectNoPublication(std.testing.io, tmp, "cancelled");
// Generation 1 was open and partly written when the cancel landed, so the
// sibling holding it is exactly what must not survive. Fail-closed.
try std.testing.expect(!script.temporary_sibling_left);
}
test "import-git unwinds a failed export child that already opened the raw generation" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// A COMPLETE, well-formed stream whose child then exits non-zero. The
// import has no parse error to fail on: the only thing wrong is the exit
// status, and trusting a stream whose producer died is exactly how a
// truncated history would publish as a whole one.
var script = import_cli.CommandScript.init(.{
.preflight = .branch_head,
.fast_export = valid_export,
.export_exit = 128,
});
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "child-failed", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.launched);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
try expectNoPublication(std.testing.io, tmp, "child-failed");
try std.testing.expect(!script.temporary_sibling_left);
}
// The premise both tests above are NAMED for, pinned from the export stream and
// the boundary log instead of from a comment.
//
// Replayed through `runFixture` rather than `scripted` because the two faults
// need no script -- a stream and an exit status are the whole fixture -- and the
// pass-through harness is the smaller thing to be wrong about. (`scripted` would
// also work now: `CommandScript.dependencies` chains a caller's observer behind
// its own. It used to REPLACE it, which is how this pairing was first written
// and how it went green while observing nothing.)
test "import-git export faults that unwind generation 1 reach the raw store and nothing past it" {
const Case = struct { name: []const u8, export_bytes: []const u8, exit_code: u8, rejected: bool };
const cases = [_]Case{
// The truncated stream `cancel_export_mid_stream` substitutes, BORROWED from
// the seam rather than retyped: a hand copy would keep passing against
// stale bytes the moment the production literal moved, and the claim
// this case makes is a claim about that exact stream.
.{
.name = "cancelled-observed",
.export_bytes = import_cli.CommandScript.cancelled_export,
.exit_code = 0,
.rejected = true,
},
// Complete and well-formed; only the child's status is wrong.
.{ .name = "child-failed-observed", .export_bytes = valid_export, .exit_code = 128, .rejected = false },
};
for (cases) |case| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// The premise, measured from the stream itself. `create_store` cannot
// state it -- that boundary is checked before `createRawGeneration`, so
// a stream rejected at record zero observes it exactly as a truncated
// one does. Replaying through the same reader with the same limits says
// which this is: at least one accepted record means the failure lands
// with generation 1 open and already written.
const reach = try exportReach(case.export_bytes);
try std.testing.expect(reach.events >= 1);
try std.testing.expectEqual(case.rejected, reach.rejected);
var recorder: PointRecorder = .{};
var git: ScriptedGit = .{ .fast_export = case.export_bytes, .export_exit = case.exit_code };
const result = runFixture(tmp, "fixture.git", case.name, false, &git, recorder.hooks());
try std.testing.expectEqual(cli.Exit.failure, result.code);
// The transaction entered `importInto` rather than failing in
// preflight or argument handling -- which is all this boundary can say.
try std.testing.expect(recorder.seen.contains(create_failure_point));
// And nothing downstream of it ran. A fault that unwound from further
// along is a different window with a different abort path, and would
// leave this pair claiming coverage it does not have.
try std.testing.expect(!recorder.seen.contains(.capture_drop_set));
try std.testing.expect(!recorder.seen.contains(.create_packed_store));
try std.testing.expect(!recorder.seen.contains(.before_rename_no_replace));
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.launched);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
try expectNoPublication(std.testing.io, tmp, case.name);
}
}
// The scripted harness passes a caller's observer THROUGH.
//
// `CommandScript.dependencies` must install its own observer -- that recorder is
// how the whole two-generation ordering in `Publication` gets written -- so the
// obvious implementation replaces the caller's, and that is what it used to do.
// The failure mode is the worst kind available to a test suite: `scripted` takes
// the observer, drops it without a diagnostic, and every assertion made through
// it passes while observing nothing.
//
// Asserted here rather than left to the doc comment, because a constraint that
// only a comment enforces is one the next caller re-discovers by shipping a
// vacuous green. A successful import walks the whole boundary sequence, so a
// dropped observer shows up as an EMPTY set against three points the run
// certainly reached.
test "import-git scripted runs report publication boundaries to a caller's observer" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var recorder: PointRecorder = .{};
var script = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = valid_export });
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "observed", false, recorder.hooks(), &result_cap);
try std.testing.expectEqual(cli.Exit.ok, result.code);
// One from each generation and one from publication, so a chain that only
// survived part of the transaction cannot satisfy this.
try std.testing.expect(recorder.seen.contains(.create_store));
try std.testing.expect(recorder.seen.contains(.create_packed_store));
try std.testing.expect(recorder.seen.contains(.before_rename_no_replace));
// The script's own recorder still ran: chaining must ADD a listener, not
// trade one for the other. `raw_frozen_before_packing` is written by
// `publicationEvent` and by nothing else.
try std.testing.expect(script.publication.raw_frozen_before_packing);
}
test "import-git reports parent sync durability uncertainty after publish while retaining the verified destination" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = valid_export });
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "durability-uncertain", false, .{ .fail_at = parent_sync_failure_point }, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expect(result.published_durability_uncertain);
var destination = try tmp.dir.openDir(std.testing.io, "durability-uncertain", .{});
defer destination.close(std.testing.io);
try std.testing.expect(exists(std.testing.io, destination, ".zit"));
try std.testing.expect(!exists(std.testing.io, destination, ".zit-import-map.jsonl"));
}
test "import-git start session failure after stderr thread closes pipes and reaps exactly once" {
var session = import_cli.ProductionGit.initForTest(
std.testing.allocator,
std.testing.io,
std.testing.environ,
&.{ helper_options.git_import_pipe_helper, "hold" },
);
defer session.deinit();
session.test_startup_failure = .after_stderr_thread;
const dependencies = session.dependencies(.real_filesystem);
try std.testing.expectError(
error.InjectedStartFailure,
dependencies.git.vtable.start_export(dependencies.git.context, "fixture.git", null),
);
try std.testing.expect(session.child == null);
try std.testing.expect(session.stderr_thread == null);
try std.testing.expect(session.stderr_file == null);
try std.testing.expect(session.reaped);
try std.testing.expectEqual(@as(usize, 1), session.reap_calls);
}
test "import-git rejects a replacement destination after rename without deleting it" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{
.preflight = .branch_head,
.fast_export = valid_export,
.replace_destination_after_rename = true,
});
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "replacement-after-rename", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expect(std.mem.indexOf(u8, result.err, "PublishedDestinationReplaced") != null);
try std.testing.expect(script.competitor_destination_intact);
var replacement = try tmp.dir.openDir(std.testing.io, "replacement-after-rename", .{
.follow_symlinks = false,
});
defer replacement.close(std.testing.io);
try std.testing.expect(exists(std.testing.io, replacement, "replacement-sentinel"));
try std.testing.expect(!script.temporary_sibling_left);
}
test "import-git reaps after termination failure and reports the lifecycle error" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var git: ScriptedGit = .{
.fast_export = malformed_export,
.lifecycle_fault = .terminate,
};
const result = runFixture(tmp, "fixture.git", "termination-failure", false, &git, .real_filesystem);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expect(std.mem.indexOf(u8, result.err, "InjectedLifecycleFailure") != null);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.terminated);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.stderr_drained);
try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped);
try expectNoPublication(std.testing.io, tmp, "termination-failure");
}
test "import-git accounts the peeled target retained for marked tags at the exact byte boundary" {
const exact_limit = try retainedBytesForExport(marked_tag_export);
var exact_tmp = std.testing.tmpDir(.{});
defer exact_tmp.cleanup();
var exact_git: ScriptedGit = .{
.fast_export = marked_tag_export,
.limits = .{ .max_retained_bytes = exact_limit },
};
const exact = runFixture(exact_tmp, "fixture.git", "marked-tag-exact", false, &exact_git, .real_filesystem);
try std.testing.expectEqual(cli.Exit.ok, exact.code);
var exceeded_tmp = std.testing.tmpDir(.{});
defer exceeded_tmp.cleanup();
var exceeded_git: ScriptedGit = .{
.fast_export = marked_tag_export,
.limits = .{ .max_retained_bytes = exact_limit - 1 },
};
const exceeded = runFixture(exceeded_tmp, "fixture.git", "marked-tag-exceeded", false, &exceeded_git, .real_filesystem);
try std.testing.expectEqual(cli.Exit.failure, exceeded.code);
try std.testing.expect(std.mem.indexOf(u8, exceeded.err, "ImporterRetainedByteLimitExceeded") != null);
try expectNoPublication(std.testing.io, exceeded_tmp, "marked-tag-exceeded");
}
/// A fast-export whose single commit lands `count` distinct paths. Wide enough,
/// at the k the tree-mode side of the comparison pins, that tree emission
/// really stores SUBMANIFEST artifacts -- without that, "the flat import stored
/// none" would be true of both modes and would prove nothing.
fn wideExport(gpa: std.mem.Allocator, count: usize) ![]u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(gpa);
try out.appendSlice(
gpa,
"feature done\n" ++
"blob\n" ++
"mark :1\n" ++
"data 5\n" ++
"hello\n" ++
"commit refs/heads/main\n" ++
"mark :2\n" ++
"author A <[email protected]> 0 +0000\n" ++
"committer A <[email protected]> 0 +0000\n" ++
"data 4\n" ++
"init\n",
);
for (0..count) |index| {
const line = try std.fmt.allocPrint(gpa, "M 100644 :1 file{d:0>6}.txt\n", .{index});
defer gpa.free(line);
try out.appendSlice(gpa, line);
}
try out.appendSlice(gpa, "\n" ++ "done\n");
return out.toOwnedSlice(gpa);
}
const ImportedSubmanifestState = struct {
enable_submanifests: bool,
submanifest_k: u8,
seen_submanifest: bool,
/// Whether ANY artifact in the produced store is a SUBMANIFEST. Read off
/// the store itself rather than off the config frame, so the two statements
/// can disagree and be caught doing it.
submanifests_stored: bool,
checkin_paths: usize,
};
/// Everything this feature claims about a produced repository, read back from
/// the repository: the four persisted sub-manifest settings, whether the store
/// holds a SUBMANIFEST artifact, and the flattened path count of the one
/// check-in. The path count is what lets the caller assert that the two modes
/// imported the SAME history.
fn readImportedSubmanifestState(io: std.Io, destination: std.Io.Dir) !ImportedSubmanifestState {
const gpa = std.testing.allocator;
var file = try destination.openFile(io, ".zit", .{});
defer file.close(io);
const stat = try file.stat(io);
var file_storage = storage.FileStorage.init(io, file, stat.size);
var log = try logstore.LogStore.openReadOnly(gpa, file_storage.storageFile());
defer log.deinit(gpa);
var owned = (try log.readConfig(gpa)) orelse return error.TestUnexpectedResult;
defer owned.deinit(gpa);
try std.testing.expect(!owned.config.submanifest_auto_retune);
const rids = try log.ridsInPersistedOrder(gpa);
defer gpa.free(rids);
var submanifests: usize = 0;
var checkins: usize = 0;
var checkin_paths: usize = 0;
for (rids) |rid| {
const bytes = (try log.content(gpa, rid)) orelse continue;
defer gpa.free(bytes);
// A blob is not parseable as an artifact; skipping is the whole
// classification here, so it must not be an error.
var manifest = artifact.parse(gpa, bytes) catch continue;
defer manifest.deinit();
switch (manifest.kind) {
.submanifest => submanifests += 1,
.manifest => {
checkins += 1;
checkin_paths = if (manifest.s.len != 0) manifest.s.len else manifest.f.len;
},
else => {},
}
}
try std.testing.expectEqual(@as(usize, 1), checkins);
return .{
.enable_submanifests = owned.config.enable_submanifests,
.submanifest_k = owned.config.submanifest_k,
.seen_submanifest = owned.config.seen_submanifest,
.submanifests_stored = submanifests != 0,
.checkin_paths = checkin_paths,
};
}
/// Every check-in manifest in the store carries only F cards, and no artifact
/// in it is a SUBMANIFEST. This is the store-side reading of §8.1's "MUST NOT
/// emit an S card"; it consults no flag and no config field.
fn expectNoSCardsAnywhere(io: std.Io, destination: std.Io.Dir) !void {
const gpa = std.testing.allocator;
var file = try destination.openFile(io, ".zit", .{});
defer file.close(io);
const stat = try file.stat(io);
var file_storage = storage.FileStorage.init(io, file, stat.size);
var log = try logstore.LogStore.openReadOnly(gpa, file_storage.storageFile());
defer log.deinit(gpa);
const rids = try log.ridsInPersistedOrder(gpa);
defer gpa.free(rids);
for (rids) |rid| {
const bytes = (try log.content(gpa, rid)) orelse continue;
defer gpa.free(bytes);
var manifest = artifact.parse(gpa, bytes) catch continue;
defer manifest.deinit();
try std.testing.expect(manifest.kind != .submanifest);
try std.testing.expectEqual(@as(usize, 0), manifest.s.len);
}
}
test "import-git --no-submanifests leaves the destination at the spec-default config with no S cards" {
const gpa = std.testing.allocator;
// 96 paths at k=1 is comfortably past the point the canonical partition
// yields more than one group, which the tree-mode half below proves by
// actually storing SUBMANIFEST artifacts from this exact stream.
const export_stream = try wideExport(gpa, 96);
defer gpa.free(export_stream);
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var flat_git: ScriptedGit = .{ .fast_export = export_stream };
const flat = runFixtureShaped(tmp, "fixture.git", "flat", false, &flat_git, .real_filesystem, .flat);
try std.testing.expectEqual(cli.Exit.ok, flat.code);
// "Do the default thing" reports nothing about sub-manifests.
try std.testing.expect(std.mem.indexOf(u8, flat.err, "sub-manifest") == null);
var tree_git: ScriptedGit = .{ .fast_export = export_stream };
const tree = runFixtureShaped(tmp, "fixture.git", "tree", false, &tree_git, .real_filesystem, .{ .tree = 1 });
try std.testing.expectEqual(cli.Exit.ok, tree.code);
// The other side of the same gate. Flat's silence above only means
// something if an unpinned tree import does report its analyzed k, so pin
// that here too -- otherwise deleting the report entirely would leave both
// halves green.
var analyzed_git: ScriptedGit = .{ .fast_export = export_stream, .analyzed_submanifest_k = 3 };
const analyzed = runFixtureShaped(tmp, "fixture.git", "analyzed", false, &analyzed_git, .real_filesystem, .{ .tree = null });
try std.testing.expectEqual(cli.Exit.ok, analyzed.code);
try std.testing.expect(std.mem.indexOf(u8, analyzed.err, "analyzed sub-manifest k=3") != null);
var flat_dir = try tmp.dir.openDir(std.testing.io, "flat", .{});
defer flat_dir.close(std.testing.io);
var tree_dir = try tmp.dir.openDir(std.testing.io, "tree", .{});
defer tree_dir.close(std.testing.io);
const flat_state = try readImportedSubmanifestState(std.testing.io, flat_dir);
const tree_state = try readImportedSubmanifestState(std.testing.io, tree_dir);
// The tree half is asserted first because it is what gives the flat half
// its meaning: this stream at this k really does produce sub-manifests, and
// today's tree-mode config frame is pinned here as the contrast.
try std.testing.expect(tree_state.submanifests_stored);
try std.testing.expect(tree_state.enable_submanifests);
try std.testing.expectEqual(@as(u8, 1), tree_state.submanifest_k);
try std.testing.expect(tree_state.seen_submanifest);
// The flat half: identical to a fresh `zit init` as far as sub-manifest
// behavior goes. `submanifest_k = 0` is "not pinned", not "k of zero".
try std.testing.expect(!flat_state.submanifests_stored);
try std.testing.expect(!flat_state.enable_submanifests);
try std.testing.expectEqual(@as(u8, 0), flat_state.submanifest_k);
try std.testing.expect(!flat_state.seen_submanifest);
try expectNoSCardsAnywhere(std.testing.io, flat_dir);
// ... and it imported the same history: every path landed, as F cards.
try std.testing.expectEqual(@as(usize, 96), flat_state.checkin_paths);
try std.testing.expect(tree_state.checkin_paths < flat_state.checkin_paths);
}
// ---------------------------------------------------------------------------
// Second generation: every successful import publishes a PACKED store.
//
// The transaction runs `writing_raw -> raw_frozen -> packing -> packed_frozen
// -> verified -> published`. Generation 1 is written under its own name and is
// never the thing that ships: it is frozen, packed into generation 2 through
// the classifier's exact candidate-only drop set, read once more by
// verification, and then deleted from staging before the directory is synced
// and renamed. `DEST/.zit` is generation 2 and nothing else.
// ---------------------------------------------------------------------------
// The two generations must be distinct FILES, not one file rewritten. A single
// name would make "pack generation 1 into generation 2" a truncating in-place
// rewrite with no recoverable source, which is the shape this transaction
// exists to avoid.
test "import-git second generation uses a file distinct from the raw generation" {
try std.testing.expect(!std.mem.eql(
u8,
import_cli.raw_generation_name,
import_cli.published_generation_name,
));
try std.testing.expectEqualStrings(".zit", import_cli.published_generation_name);
}
test "import-git second generation advances its publication stages one way only" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var bundle = try import_cli.PublicationBundle.create(std.testing.allocator, io, tmp.dir, "one-way");
defer bundle.abort();
// Skipping ahead is rejected...
try std.testing.expectError(error.PublicationStageOutOfOrder, bundle.advance(.packing));
try std.testing.expectEqual(import_cli.PublicationBundle.Stage.writing_raw, bundle.stage);
// ...one step at a time is accepted...
try bundle.advance(.raw_frozen);
try bundle.advance(.packing);
// ...repeating a stage is rejected...
try std.testing.expectError(error.PublicationStageOutOfOrder, bundle.advance(.packing));
// ...and so is going back.
try std.testing.expectError(error.PublicationStageOutOfOrder, bundle.advance(.raw_frozen));
try std.testing.expectEqual(import_cli.PublicationBundle.Stage.packing, bundle.stage);
}
test "import-git second generation publish rejects every state earlier than verified" {
const io = std.testing.io;
const early = [_]import_cli.PublicationBundle.Stage{
.writing_raw,
.raw_frozen,
.packing,
.packed_frozen,
};
for (early) |stage| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var bundle = try import_cli.PublicationBundle.create(std.testing.allocator, io, tmp.dir, "too-early");
defer bundle.abort();
bundle.stage = stage;
try std.testing.expectError(
error.PublicationNotVerified,
bundle.publish("too-early", .real_filesystem),
);
try std.testing.expect(!directoryExists(io, tmp.dir, "too-early"));
}
}
test "import-git second generation publishes the packed generation and no raw generation" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = valid_export });
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "second-generation", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.ok, result.code);
var destination = try tmp.dir.openDir(io, "second-generation", .{});
defer destination.close(io);
try std.testing.expect(exists(io, destination, import_cli.published_generation_name));
// The rename ships the whole staging directory, so a surviving generation 1
// would land in DEST. It must be gone before the commit point.
try std.testing.expect(!exists(io, destination, import_cli.raw_generation_name));
// The default publication is the repository and its checkout record --
// instrumentation leaves staging with the raw generation unless
// --provenance kept it.
try std.testing.expect(!exists(io, destination, ".zit-import-map.jsonl"));
try std.testing.expect(!exists(io, destination, "refs"));
// Stage ordering, observed rather than asserted from the source.
try std.testing.expect(script.publication.raw_frozen_before_packing);
try std.testing.expect(script.publication.packed_generation_created);
try std.testing.expect(script.publication.packed_before_verify);
try std.testing.expect(script.publication.both_generations_present_before_discard);
try std.testing.expect(script.publication.raw_generation_discarded);
try std.testing.expect(script.publication.discard_before_rename);
try std.testing.expect(script.publication.verify_before_publish);
// The published store is a complete log in its own right.
var store_file = try destination.openFile(io, import_cli.published_generation_name, .{});
defer store_file.close(io);
const stat = try store_file.stat(io);
var file_storage = storage.FileStorage.init(io, store_file, stat.size);
var log = try logstore.LogStore.openReadOnly(std.testing.allocator, file_storage.storageFile());
defer log.deinit(std.testing.allocator);
try std.testing.expect(log.entryCount() != 0);
}
// The pack report is the only place an operator learns that publication is
// packed at all, and the only externally visible statement of how many mirror
// artifacts the classifier authorized dropping. A stream with no Fossil mirror
// pair must report zero -- fail-closed, stated out loud.
test "import-git second generation reports its pack census and drops nothing without a mirror pair" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var git: ScriptedGit = .{ .fast_export = valid_export };
const result = runFixture(tmp, "fixture.git", "census", false, &git, .real_filesystem);
try std.testing.expectEqual(cli.Exit.ok, result.code);
try std.testing.expect(std.mem.indexOf(u8, result.err, "import-git: packed ") != null);
try std.testing.expect(std.mem.indexOf(u8, result.err, "dropped 0 mirror artifacts") != null);
}
// The eleven packing-stage boundaries used to be swept again here, from a
// SIXTH `FailurePoint` array local to this test. Deleted rather than kept: all
// eleven are in `pre_rename_failure_points`, and that sweep asserts a strict
// superset of what this one did (`Exit.failure`, `launched == 1`, `reaped == 1`,
// `expectNoPublication`, and `!temporary_sibling_left`, against this test's
// failure/reaped/no-publication). A duplicate list is not extra coverage; it is
// a second registration site the partition gate above cannot see, which is the
// exact drift that gate exists to stop.
// A competitor that appears at the commit point must survive untouched even
// though the transaction now has two generations to unwind, and the staging
// sibling holding BOTH of them must still be removed.
test "import-git second generation never clobbers a competing destination and leaves no sibling" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{
.preflight = .branch_head,
.fast_export = valid_export,
.competing_destination_at_publish = true,
});
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "packed-raced", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
try std.testing.expect(script.competitor_destination_intact);
try std.testing.expect(!script.temporary_sibling_left);
// The competitor is a bare directory the script created: it must still be
// bare, with neither generation written into it.
var competitor = try tmp.dir.openDir(std.testing.io, "packed-raced", .{});
defer competitor.close(std.testing.io);
try std.testing.expect(!exists(std.testing.io, competitor, import_cli.published_generation_name));
try std.testing.expect(!exists(std.testing.io, competitor, import_cli.raw_generation_name));
}
// The manifest check is what makes "only generation 2 is published" a property
// of the DIRECTORY at the instant it is renamed, rather than a property of the
// statement order that got it there. Stand in for a future edit that writes an
// intermediate file and forgets to remove it.
test "import-git second generation refuses to publish staging holding anything unmanifested" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{
.preflight = .branch_head,
.fast_export = valid_export,
.stray_staging_file_after_discard = true,
});
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "stray", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.failure, result.code);
// The stray arrives AFTER the discard, so the transaction was otherwise
// complete: only the manifest check stands between it and the rename.
try std.testing.expect(script.publication.raw_generation_discarded);
try expectNoPublication(std.testing.io, tmp, "stray");
}
// `.verified` is both the pre-discard and the post-discard stage, so the stage
// alone cannot say whether generation 1 left staging. The commit point checks
// the fact directly.
test "import-git second generation publish rejects a bundle still holding the raw generation" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var bundle = try import_cli.PublicationBundle.create(std.testing.allocator, io, tmp.dir, "raw-held");
defer bundle.abort();
bundle.stage = .verified;
bundle.raw_present = true;
try std.testing.expectError(
error.PublicationRawGenerationPresent,
bundle.publish("raw-held", .real_filesystem),
);
try std.testing.expect(!directoryExists(io, tmp.dir, "raw-held"));
// The gate is the raw generation and nothing else: once it is gone, the
// same bundle at the same stage publishes.
bundle.raw_present = false;
try bundle.publish("raw-held", .real_filesystem);
try std.testing.expect(directoryExists(io, tmp.dir, "raw-held"));
}
// ===========================================================================
// Task 6 Step 1 -- the eleven corruption fixtures.
//
// RED BY CONSTRUCTION. The ordered verification pipeline of Step 2 does not
// exist: `verifyFrozen` today reopens both generations headers-only, reads the
// checkout, and opens the map without reading a byte of it. So every fixture
// below injects a real divergence between the frozen generations and what the
// import decided, and then asserts the ONE diagnostic that names its clause.
//
// Two assertions per fixture, and both are load-bearing:
//
// 1. `script.verifier_fault_injected` -- the corruption actually happened.
// Without it a fixture whose injection silently no-ops would go green the
// moment the pipeline lands, which is the failure mode this suite exists
// to prevent. A fixture that passes for the wrong reason is worse than a
// missing fixture.
// 2. the clause's own diagnostic in stderr -- not merely `.failure`. A
// verifier that refuses generically is nearly as bad as none: the
// operator cannot tell a dropped artifact from a tampered provenance
// line, and the whole point of Task 6 is that Tasks 3-5 DELETE artifacts
// from a repository and publish the result.
//
// The diagnostic tokens below are the contract Step 2 implements. They are
// deliberately clause names, not error names: `verifier:` plus the invariant.
// ===========================================================================
const verifier_clause_prefix = "import-git: verifier: ";
/// Two files, distinct author and committer, a two-parent merge, a lightweight
/// tag and an annotated tag. One stream so that ten of the eleven fixtures
/// differ only in what they corrupt, never in what they imported.
const verifier_export =
"feature done\n" ++
"blob\n" ++
"mark :1\n" ++
"data 5\n" ++
"alpha\n" ++
"blob\n" ++
"mark :2\n" ++
"data 4\n" ++
"beta\n" ++
"commit refs/heads/main\n" ++
"mark :3\n" ++
"original-oid 1111111111111111111111111111111111111111\n" ++
"author Author One <[email protected]> 1700000000 +0000\n" ++
"committer Committer Two <[email protected]> 1700000100 +0000\n" ++
"data 4\n" ++
"root\n" ++
"M 100644 :1 a.txt\n" ++
"M 100644 :2 b.txt\n" ++
"\n" ++
"blob\n" ++
"mark :4\n" ++
"data 5\n" ++
"gamma\n" ++
"commit refs/heads/side\n" ++
"mark :5\n" ++
"original-oid 2222222222222222222222222222222222222222\n" ++
"author Author One <[email protected]> 1700000200 +0000\n" ++
"committer Committer Two <[email protected]> 1700000300 +0000\n" ++
"data 4\n" ++
"side\n" ++
"from :3\n" ++
"M 100644 :4 b.txt\n" ++
"\n" ++
"commit refs/heads/main\n" ++
"mark :6\n" ++
"original-oid 3333333333333333333333333333333333333333\n" ++
"author Author One <[email protected]> 1700000400 +0000\n" ++
"committer Committer Two <[email protected]> 1700000500 +0000\n" ++
"data 5\n" ++
"merge\n" ++
"from :3\n" ++
"merge :5\n" ++
"\n" ++
"reset refs/tags/light\n" ++
"from :3\n" ++
"\n" ++
"tag v1\n" ++
"mark :7\n" ++
"from :6\n" ++
"tagger Author One <[email protected]> 1700000600 +0000\n" ++
"data 0\n" ++
"\n" ++
"done\n";
fn appendFmt(out: *std.ArrayList(u8), gpa: std.mem.Allocator, comptime fmt: []const u8, args: anytype) !void {
const rendered = try std.fmt.allocPrint(gpa, fmt, args);
defer gpa.free(rendered);
try out.appendSlice(gpa, rendered);
}
/// A stream carrying a VALID Fossil mirror pair, so that the drop set is not
/// empty and `retained_omitted_artifact` has a referent.
///
/// Built at runtime because two of the four blobs are hashes of the other two:
/// `manifest.uuid` must name the sterilized `manifest` bytes, and those bytes
/// must carry an F card naming the tree file. A string literal cannot close
/// that loop. Gates 1-8 of `classifyMirror` are what this satisfies -- one
/// regular-mode candidate set, a canonical uuid over the sterilized manifest,
/// a flat check-in whose file set is the candidate-free tree, no parents on a
/// root commit, a D card at the committer instant, and a well-formed tags file.
fn buildMirrorExport(gpa: std.mem.Allocator) ![]u8 {
const sterilizer = "# Remove this line to create a well-formed Fossil manifest.\n";
const tree_content = "alpha\n";
const tags_content = "branch trunk\ntag trunk\n";
// 1700000000 as a Fossil D card; the export's committer instant must be
// the same second or gate 7 retains the pair.
const instant = "2023-11-14T22:13:20";
var name_buf: [64]u8 = undefined;
const tree_hash = artifact.artifactName(.sha1, tree_content, &name_buf);
var manifest: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .manifest,
.a = null,
.b = null,
.c = .{ .comment = "mirror" },
.d = .{ .timestamp = instant },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "mirror" },
.z = null,
.w = null,
.f = &.{},
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.s = &.{},
.t = &.{},
};
defer manifest.deinit();
var cards = [_]artifact.FCard{.{
.filename = "a.txt",
.hash = try manifest.arena_state.allocator().dupe(u8, tree_hash),
.perm = null,
.old_name = null,
.mode = .regular,
}};
manifest.f = &cards;
const manifest_bytes = try artifact.create(&manifest, gpa);
defer gpa.free(manifest_bytes);
var uuid_buf: [64]u8 = undefined;
const uuid = artifact.artifactName(.sha1, manifest_bytes, &uuid_buf);
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(gpa);
try appendFmt(&out, gpa, "feature done\nblob\nmark :1\ndata {d}\n{s}\n", .{ tree_content.len, tree_content });
try appendFmt(&out, gpa, "blob\nmark :2\ndata {d}\n{s}{s}\n", .{ manifest_bytes.len + sterilizer.len, manifest_bytes, sterilizer });
try appendFmt(&out, gpa, "blob\nmark :3\ndata {d}\n{s}\n\n", .{ uuid.len + 1, uuid });
try appendFmt(&out, gpa, "blob\nmark :4\ndata {d}\n{s}\n", .{ tags_content.len, tags_content });
try out.appendSlice(
gpa,
"commit refs/heads/main\n" ++
"mark :5\n" ++
"original-oid 4444444444444444444444444444444444444444\n" ++
"author Mirror <[email protected]> 1700000000 +0000\n" ++
"committer Mirror <[email protected]> 1700000000 +0000\n" ++
"data 6\nmirror\n" ++
"M 100644 :1 a.txt\n" ++
"M 100644 :2 manifest\n" ++
"M 100644 :3 manifest.uuid\n" ++
"M 100644 :4 manifest.tags\n" ++
"\ndone\n",
);
return out.toOwnedSlice(gpa);
}
/// One corruption fixture. Runs the whole transaction with `fault` injected at
/// `.verify_raw_store`, then requires that the named clause -- and only the
/// named clause -- is what refused the rename.
fn expectVerifierClause(
destination: []const u8,
source_export: []const u8,
fault: import_cli.CommandScript.VerifierFault,
clause: []const u8,
) !void {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{
.preflight = .branch_head,
.fast_export = source_export,
.verifier_fault = fault,
});
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, destination, false, .real_filesystem, &result_cap);
// The corruption happened. Asserted first and separately, so a fixture
// whose injection no-ops reports THAT and never masquerades as a verifier
// result.
if (!script.verifier_fault_injected) {
std.debug.print(
"\n [{s}] INJECTION DID NOT HAPPEN -- the fixture corrupted nothing, so it says nothing about `{s}`.\n",
.{ destination, clause },
);
return error.VerifierFaultNotInjected;
}
// It refused for this reason. Checked before the exit code, because
// "refused generically" and "refused for the right reason" are different
// results and only the second one proves a clause exists.
if (std.mem.indexOf(u8, result.err, clause) == null) {
std.debug.print(
"\n [{s}] MISSING CLAUSE `{s}`\n exit: {s}\n published: {}\n stderr: {s}\n",
.{ destination, clause, @tagName(result.code), directoryExists(std.testing.io, tmp.dir, destination), result.err },
);
return error.VerifierClauseMissing;
}
// And ONLY that clause. The pipeline returns at its first refusal, so a
// second `verifier:` line would mean the fixture tripped something else on
// the way and this assertion is what keeps "the clause appears" from
// drifting into "the clause appears somewhere among several".
var scan: usize = 0;
var lines: usize = 0;
while (std.mem.indexOfPos(u8, result.err, scan, verifier_clause_prefix)) |at| {
lines += 1;
scan = at + verifier_clause_prefix.len;
}
if (lines != 1) {
std.debug.print(
"\n [{s}] EXPECTED EXACTLY ONE VERIFIER CLAUSE, SAW {d}\n stderr: {s}\n",
.{ destination, lines, result.err },
);
return error.VerifierClauseAmbiguous;
}
// The verifier refused, and DEST was never created.
try std.testing.expectEqual(cli.Exit.failure, result.code);
try expectNoPublication(std.testing.io, tmp, destination);
}
test "import-git exhaustive verifier rejects an omitted artifact retained in the packed generation" {
const mirror_export = try buildMirrorExport(std.testing.allocator);
defer std.testing.allocator.free(mirror_export);
// Guard the fixture's own premise before asserting anything about the
// verifier: with an empty drop set there is nothing to retain, and the
// corruption would be unexpressible rather than undetected.
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = mirror_export });
var census_cap: import_cli.ScriptCapture = .{};
const census = scripted(tmp, &script, "mirror-census", false, .real_filesystem, &census_cap);
try std.testing.expectEqual(cli.Exit.ok, census.code);
try std.testing.expect(std.mem.indexOf(u8, census.err, "dropped 0 mirror artifacts") == null);
}
try expectVerifierClause(
"verifier-retained-omitted",
mirror_export,
.retained_omitted_artifact,
verifier_clause_prefix ++ "retained-omitted-artifact",
);
}
test "import-git exhaustive verifier rejects a required artifact dropped from the packed generation" {
try expectVerifierClause(
"verifier-dropped-required",
verifier_export,
.dropped_required_artifact,
verifier_clause_prefix ++ "dropped-required-artifact",
);
}
test "import-git exhaustive verifier rejects an artifact added to the packed generation" {
try expectVerifierClause(
"verifier-added",
verifier_export,
.added_artifact,
verifier_clause_prefix ++ "added-artifact",
);
}
test "import-git exhaustive verifier rejects changed artifact bytes under an unchanged name" {
try expectVerifierClause(
"verifier-changed-bytes",
verifier_export,
.changed_artifact_bytes,
verifier_clause_prefix ++ "artifact-rehash-mismatch",
);
}
test "import-git exhaustive verifier rejects a delta frame recorded against the wrong base" {
try expectVerifierClause(
"verifier-wrong-delta-base",
verifier_export,
.wrong_delta_base,
verifier_clause_prefix ++ "delta-base-mismatch",
);
}
test "import-git exhaustive verifier rejects an artifact-set-preserving tree change" {
try expectVerifierClause(
"verifier-tree-change",
verifier_export,
.tree_change_preserving_artifacts,
verifier_clause_prefix ++ "normalized-tree-digest-mismatch",
);
}
test "import-git exhaustive verifier rejects reordered check-in parents" {
try expectVerifierClause(
"verifier-parent-reorder",
verifier_export,
.parent_reorder,
verifier_clause_prefix ++ "parent-order-mismatch",
);
}
test "import-git exhaustive verifier rejects a retargeted branch ref" {
try expectVerifierClause(
"verifier-ref-retarget",
verifier_export,
.ref_retarget,
verifier_clause_prefix ++ "ref-target-mismatch",
);
}
test "import-git exhaustive verifier rejects a changed tag target or kind" {
try expectVerifierClause(
"verifier-tag-change",
verifier_export,
.tag_change,
verifier_clause_prefix ++ "tag-mismatch",
);
}
test "import-git exhaustive verifier rejects changed attribution in the finalized map" {
try expectVerifierClause(
"verifier-attribution",
verifier_export,
.attribution_change,
verifier_clause_prefix ++ "attribution-mismatch",
);
}
test "import-git exhaustive verifier rejects a tampered provenance line" {
try expectVerifierClause(
"verifier-provenance",
verifier_export,
.provenance_tamper,
verifier_clause_prefix ++ "provenance-digest-mismatch",
);
}
// The negative control. Same stream, no injection: the transaction must reach
// `verified` and publish, so no fixture above can be passing merely because
// this export is unimportable.
test "import-git exhaustive verifier admits the uncorrupted fixture" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{
.preflight = .branch_head,
.fast_export = verifier_export,
});
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "verifier-clean", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.ok, result.code);
try std.testing.expect(!script.verifier_fault_injected);
try std.testing.expect(script.publication.verify_before_publish);
try std.testing.expect(std.mem.indexOf(u8, result.err, "import-git: verifier: ") == null);
try std.testing.expect(directoryExists(std.testing.io, tmp.dir, "verifier-clean"));
}
// The fixpoint, proved OUTSIDE the transaction and deliberately so.
//
// Design item 6 makes the B-to-C proof a qualification layer, not a runtime
// one: the production import creates generation 1 and generation 2 and stops,
// because a third generation is an extra full pack of the whole repository on
// every import to re-establish a property that does not change between runs.
// This is where that property is established instead -- on a real published
// import, over a stream carrying a validated mirror pair, so the pack whose
// determinism is under test is one that actually dropped something.
test "import-git pack is idempotent: repacking a published generation is byte-identical" {
const io = std.testing.io;
const gpa = std.testing.allocator;
const mirror_export = try buildMirrorExport(gpa);
defer gpa.free(mirror_export);
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var script = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = mirror_export });
var result_cap: import_cli.ScriptCapture = .{};
const result = scripted(tmp, &script, "fixpoint", false, .real_filesystem, &result_cap);
try std.testing.expectEqual(cli.Exit.ok, result.code);
// The premise: generation 2 is a pack that removed something. A fixpoint
// over a pack that dropped nothing would prove far less.
try std.testing.expect(std.mem.indexOf(u8, result.err, "dropped 0 mirror artifacts") == null);
var published = try tmp.dir.openDir(io, "fixpoint", .{});
defer published.close(io);
var b_file = try published.openFile(io, import_cli.published_generation_name, .{ .mode = .read_only });
defer b_file.close(io);
var b_storage = storage.FileStorage.init(io, b_file, (try b_file.stat(io)).size);
var b = try logstore.LogStore.openReadOnly(gpa, b_storage.storageFile());
defer b.deinit(gpa);
// C, disposable, in the test's own directory: nothing about this generation
// is ever published, and the import that produced B never created it.
var c_file = try tmp.dir.createFile(io, "generation-c", .{ .read = true, .exclusive = true });
defer c_file.close(io);
var c_storage = storage.FileStorage.init(io, c_file, 0);
var c = try logstore.LogStore.open(gpa, c_storage.storageFile(), b.hash_kind);
defer c.deinit(gpa);
// No filter and no extra shuns: everything B holds is content the import
// already proved it should keep, so C differs from B only if packing is
// not deterministic over its own output.
const stats = try pack.packLog(gpa, &b, &c, pack.Filter.keep_all, &.{});
try std.testing.expectEqual(@as(usize, 0), stats.dropped);
const b_bytes = try published.readFileAlloc(io, import_cli.published_generation_name, gpa, .limited(1 << 24));
defer gpa.free(b_bytes);
const c_bytes = try tmp.dir.readFileAlloc(io, "generation-c", gpa, .limited(1 << 24));
defer gpa.free(c_bytes);
try std.testing.expectEqualSlices(u8, b_bytes, c_bytes);
}