Artifact 6ef6a01ef241eaf18e5d196fa493bca5caa99676f5d543c4d0e889f3c86efdea:
- File
src/git_import_cli_test.zig
— part of check-in
[9b96469ae6]
at
2026-08-02 01:29:11
on branch sub-manifest
— import-git: add --no-submanifests
Imports with sub-manifests off: flat manifests throughout and the spec-default config frame (k=0, enable off). Flat is §8.1's default shape; it is this verb's analyze-and-pin that is the deviation.
TreeFormat.Shape becomes flat | tree{k, height_max}, so flat-with-a-k and tree-without-one stop being representable and every tree-only site traps on the wrong tag. buildFlat drains the same MergedCursor, so ordering, dedup and rename/copy/delete stay single-sourced. SubmanifestChoice replaces submanifest_k from Request down to UserOptions.
Publication refuses if the store holds a SUBMANIFEST anyway, derived from work done rather than from the flag. Preflight analysis still runs under flat -- skipping it would put a second divergence inside the region the benchmark times.
Closes #23. (user: dmestas, size: 69851) [annotate] [blame] [check-ins using]
//! 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("cli.zig"); const fast_export = @import("zit").git_fast_export; const import_cli = @import("git_import_cli.zig"); const checkout = @import("zit").checkout; const artifact = @import("zit").artifact; const git_import = @import("zit").git_import; const logstore = @import("zit").logstore; const storage = @import("zit").storage; 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"; 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, .terminate = terminate, .drain_stderr = drainStderr, .reap = reap, }, }, .publication = publication, .limits = self.limits, }; } fn startExport(context: *anyopaque, _: []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 { 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, .submanifest = submanifest, .attribution = .committer, .default_branch = null, .quiet = quiet, }, 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, }); 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; } 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; } 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")); var entries = tmp.dir.iterate(); while (try entries.next(io)) |entry| { try std.testing.expect(!std.mem.startsWith(u8, entry.name, ".zit-import-tmp-")); } } test "import-git 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 = runFixture( tmp, "fixture.git", "imported", false, &git, .real_filesystem, ); 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); try std.testing.expectEqualStrings("main", 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 = runFixture(tmp, "fixture.git", "with-remote", false, &git, .real_filesystem); 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 809fa79c883c54adff3a115e235f83f60327e4d2\n" ++ "sym-refs/remotes/origin/main 809fa79c883c54adff3a115e235f83f60327e4d2\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"); } 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_store }, ); 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"); } test "import-git removes all temporary output when publication flush or rename fails" { const failures = [_]import_cli.PublicationHooks.FailurePoint{ .flush_store, .sync_map, .rename_no_replace }; for (failures) |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); 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, ) import_cli.ScriptResult { return import_cli.runScripted( std.testing.allocator, std.testing.io, .{ .source = "fixture.git", .destination_parent = tmp.dir, .destination_name = destination, .submanifest = .{ .tree = 5 }, .attribution = .committer, .default_branch = null, .quiet = quiet, }, script, fault, ); } 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, &.{ helper_options.git_import_pipe_helper, mode }, ); 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 code = import_cli.run( std.testing.allocator, io, .{ .source = "fixture.git", .destination_parent = tmp.dir, .destination_name = 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); defer session.deinit(); return import_cli.run( std.testing.allocator, std.testing.io, .{ .source = source, .destination_parent = tmp.dir, .destination_name = 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 --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 "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", "--refuse-signed", "--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 }); const result = scripted(tmp, &script, "rejected", false, .real_filesystem); 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 }); const rejected = scripted(tmp, &script, "detached", false, .real_filesystem); 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"; const accepted = scripted(tmp, &script, "detached-with-branch", false, .real_filesystem); 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 }); const result = scripted(tmp, &script, "argv", false, .real_filesystem); 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); try std.testing.expect(result.err.len < 4096); 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 }); const result = scripted(tmp, &script, "verified", false, .real_filesystem); 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, }); const result = scripted(tmp, &script, "raced", false, .real_filesystem); 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, }); const result = scripted(tmp, &script, "raced-staging", false, .real_filesystem); 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 }; 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_export_events >= 400_000); try std.testing.expect(limits.max_retained_entries >= 6 * 400_000); 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_events = 3, .max_export_payload_bytes = one_command_budget, .max_retained_entries = 7, .max_retained_bytes = 1024, }; const RejectedLimit = struct { limits: import_cli.ImportLimits, error_name: []const u8, }; const rejected_limits = [_]RejectedLimit{ .{ .limits = .{ .max_export_events = accepted_limits.max_export_events - 1, .max_export_payload_bytes = accepted_limits.max_export_payload_bytes, .max_retained_entries = accepted_limits.max_retained_entries, }, .error_name = "ExportEventLimitExceeded", }, .{ // 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_events = accepted_limits.max_export_events, .max_export_payload_bytes = 8, .max_retained_entries = accepted_limits.max_retained_entries, }, .error_name = "ExportPayloadLimitExceeded", }, .{ .limits = .{ .max_export_events = accepted_limits.max_export_events, .max_export_payload_bytes = accepted_limits.max_export_payload_bytes, .max_retained_entries = accepted_limits.max_retained_entries - 1, }, .error_name = "ImporterRetainedEntryLimitExceeded", }, .{ .limits = .{ .max_export_events = accepted_limits.max_export_events, .max_export_payload_bytes = accepted_limits.max_export_payload_bytes, .max_retained_entries = accepted_limits.max_retained_entries, .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, }); const result = scripted(tmp, &script, "at-limit", false, .real_filesystem); 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}); const result = scripted(tmp, &script, destination_name, false, .real_filesystem); 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_export_events = accepted_limits.max_export_events, .max_command_arena_bytes = one_command_budget - 1, .max_retained_entries = accepted_limits.max_retained_entries, .max_retained_bytes = accepted_limits.max_retained_bytes, }, }); const result = scripted(tmp, &script, "command-ceiling", false, .real_filesystem); 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_events = accepted_limits.max_export_events, .max_export_payload_bytes = 8, .max_retained_entries = accepted_limits.max_retained_entries, .max_retained_bytes = accepted_limits.max_retained_bytes, }, }); const result = scripted(tmp, &script, "payload-ceiling", false, .real_filesystem); 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"); } { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var script = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = "feature done\nprogress first\nprogress second\n", .limits = .{ .max_export_events = 1 }, }); const result = scripted(tmp, &script, "progress-limit", false, .real_filesystem); try std.testing.expectEqual(cli.Exit.failure, result.code); try std.testing.expect(std.mem.indexOf(u8, result.err, "ExportEventLimitExceeded") != 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, "progress-limit"); } } test "import-git aborts every pre-rename writable-boundary failure without a partial destination" { const failures = [_]import_cli.PublicationHooks.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, .before_sync_temporary_directory, .after_sync_temporary_directory, .before_rename_no_replace, }; for (failures) |failure| { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var script = import_cli.CommandScript.init(.{ .preflight = .branch_head, .fast_export = valid_export }); const result = scripted(tmp, &script, "boundary-failed", false, .{ .fail_at = failure }); try std.testing.expectEqual(cli.Exit.failure, result.code); try std.testing.expectEqual(@as(usize, 1), result.lifecycle.reaped); try expectNoPublication(std.testing.io, tmp, "boundary-failed"); } } 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 }); const result = scripted(tmp, &script, "durability-uncertain", false, .{ .fail_at = .sync_parent_after_publish }); 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, &.{ 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"), ); 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, }); const result = scripted(tmp, &script, "replacement-after-rename", false, .real_filesystem); 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); }