Something went wrong. Try again.
Reading and writing git repositories in Zig, without running git.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Repositories for the tests to read, made with git itself in a directory//! of their own and thrown away afterwards. git is the oracle: what these//! tests expect is what it says.//!//! git runs with nobody's configuration — no global or system file, no//! hooks, no signing — and a fixed author and clock, so a test sees the//! same history on every machine.//!//! The object format is SHA-1, or what `ZIG_GIT_TEST_OBJECT_FORMAT` names://! `zig build test` runs every test a second time with it set to `sha256`,//! so that each one that reads a repository reads one of each.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;const testing = std.testing;
const ObjectFormat = @import("../object.zig").Format;
pub const Repo = struct { gpa: Allocator, io: Io, tmp: testing.TmpDir, /// Absolute. path: [:0]u8, env: std.process.Environ.Map, /// Commits made so far, for their timestamps. count: u32 = 0, /// The format the repository was made with. object_format: ObjectFormat,
pub const Options = struct { /// How git keeps refs: `files` or `reftable`; git's default if null. ref_format: ?[]const u8 = null, };
/// An empty repository whose branch is `main`. Skips the test when /// there is no git to make one with. pub fn init(gpa: Allocator, io: Io) !Repo { return initWith(gpa, io, .{}); }
/// `init`, with options. pub fn initWith(gpa: Allocator, io: Io, options: Options) !Repo { var tmp = testing.tmpDir(.{}); errdefer tmp.cleanup(); const path = try tmp.dir.realPathFileAlloc(io, ".", gpa); errdefer gpa.free(path);
var env: std.process.Environ.Map = .init(gpa); errdefer env.deinit(); if (testing.environ.getAlloc(gpa, "PATH")) |p| { defer gpa.free(p); try env.put("PATH", p); } else |_| {} try env.put("HOME", path); try env.put("GIT_CONFIG_NOSYSTEM", "1"); try env.put("GIT_CONFIG_GLOBAL", "/dev/null"); try env.put("GIT_AUTHOR_NAME", "Fixture Author"); try env.put("GIT_AUTHOR_EMAIL", "author@example.com"); try env.put("GIT_COMMITTER_NAME", "Fixture Committer"); try env.put("GIT_COMMITTER_EMAIL", "committer@example.com"); try env.put("TZ", "UTC"); // No automatic maintenance, which may repack in the background // after a commit, racing a test that repacks on purpose. try env.put("GIT_CONFIG_COUNT", "2"); try env.put("GIT_CONFIG_KEY_0", "maintenance.auto"); try env.put("GIT_CONFIG_VALUE_0", "false"); try env.put("GIT_CONFIG_KEY_1", "gc.auto"); try env.put("GIT_CONFIG_VALUE_1", "0"); var object_format: ObjectFormat = .sha1; if (testing.environ.getAlloc(gpa, "ZIG_GIT_TEST_OBJECT_FORMAT")) |f| { defer gpa.free(f); object_format = ObjectFormat.fromName(f) orelse return error.UnknownObjectFormat; try env.put("GIT_DEFAULT_HASH", f); } else |_| {}
var r: Repo = .{ .gpa = gpa, .io = io, .tmp = tmp, .path = path, .env = env, .object_format = object_format }; var ref_format_buf: [64]u8 = undefined; const init_args: []const []const u8 = if (options.ref_format) |rf| &.{ "init", "-q", "-b", "main", try std.fmt.bufPrint(&ref_format_buf, "--ref-format={s}", .{rf}) } else &.{ "init", "-q", "-b", "main" }; r.git(init_args) catch |err| switch (err) { error.FileNotFound => return error.SkipZigTest, else => return err, }; return r; }
pub fn deinit(r: *Repo) void { r.env.deinit(); r.gpa.free(r.path); r.tmp.cleanup(); r.* = undefined; }
/// Runs git in the repository, and returns what it wrote, which the /// caller frees; fails unless it succeeds. pub fn output(r: *Repo, args: []const []const u8) ![]u8 { const argv = try r.gpa.alloc([]const u8, args.len + 1); defer r.gpa.free(argv); argv[0] = "git"; @memcpy(argv[1..], args); const result = try std.process.run(r.gpa, r.io, .{ .argv = argv, .cwd = .{ .path = r.path }, .environ_map = &r.env, }); defer r.gpa.free(result.stderr); errdefer r.gpa.free(result.stdout); const ok = switch (result.term) { .exited => |code| code == 0, else => false, }; if (!ok) return error.GitFailed; return result.stdout; }
/// Runs git in the repository, and fails unless it succeeds. pub fn git(r: *Repo, args: []const []const u8) !void { const argv = try r.gpa.alloc([]const u8, args.len + 1); defer r.gpa.free(argv); argv[0] = "git"; @memcpy(argv[1..], args); const result = try std.process.run(r.gpa, r.io, .{ .argv = argv, .cwd = .{ .path = r.path }, .environ_map = &r.env, }); defer r.gpa.free(result.stdout); defer r.gpa.free(result.stderr); const ok = switch (result.term) { .exited => |code| code == 0, else => false, }; if (!ok) { std.debug.print("git {s} failed: {s}\n", .{ args[0], result.stderr }); return error.GitFailed; } }
/// What a git command printed and how it exited. pub const Outcome = struct { stdout: []u8, stderr: []u8, code: u8,
pub fn deinit(o: Outcome, gpa: Allocator) void { gpa.free(o.stdout); gpa.free(o.stderr); } };
/// Runs git in the repository, or in `cwd` relative to it, with /// `input` on its standard input, and returns what it printed and how /// it exited, whether it succeeded or not. pub fn runIn(r: *Repo, cwd: []const u8, args: []const []const u8, input: []const u8) !Outcome { const gpa = r.gpa; const argv = try gpa.alloc([]const u8, args.len + 1); defer gpa.free(argv); argv[0] = "git"; @memcpy(argv[1..], args); const input_path = "zig-git-stdin"; try r.tmp.dir.writeFile(r.io, .{ .sub_path = input_path, .data = input }); defer r.tmp.dir.deleteFile(r.io, input_path) catch {}; const stdin = try r.tmp.dir.openFile(r.io, input_path, .{}); defer stdin.close(r.io); const dir = try std.fs.path.join(gpa, &.{ r.path, cwd }); defer gpa.free(dir); var child = try std.process.spawn(r.io, .{ .argv = argv, .cwd = .{ .path = dir }, .environ_map = &r.env, .stdin = .{ .file = stdin }, .stdout = .pipe, .stderr = .pipe, }); defer child.kill(r.io); var buffer: Io.File.MultiReader.Buffer(2) = undefined; var multi: Io.File.MultiReader = undefined; multi.init(gpa, r.io, buffer.toStreams(), &.{ child.stdout.?, child.stderr.? }); defer multi.deinit(); while (multi.fill(64, .none)) |_| {} else |err| switch (err) { error.EndOfStream => {}, else => |e| return e, } try multi.checkAnyError(); const term = try child.wait(r.io); const stdout = try multi.toOwnedSlice(0); errdefer gpa.free(stdout); const stderr = try multi.toOwnedSlice(1); return .{ .stdout = stdout, .stderr = stderr, .code = switch (term) { .exited => |code| code, else => 255, } }; }
/// Runs git in the repository with `input` on its standard input, and /// returns what it wrote, which the caller frees; fails unless it /// succeeds. pub fn outputWithInput(r: *Repo, args: []const []const u8, input: []const u8) ![]u8 { const o = try r.runIn(".", args, input); errdefer o.deinit(r.gpa); if (o.code != 0) { std.debug.print("git {s} failed: {s}\n", .{ args[0], o.stderr }); return error.GitFailed; } r.gpa.free(o.stderr); return o.stdout; }
/// Writes an object of `kind` holding `contents` with `git hash-object /// --literally`, which takes whatever it is given — a commit with no /// tree, a tree with a malformed entry, a kind git has never heard of — /// and returns its object ID in hexadecimal, which the caller frees. pub fn hashObject(r: *Repo, kind: []const u8, contents: []const u8) ![]u8 { const input = ".git/zig-git-hash-object-input"; try r.tmp.dir.writeFile(r.io, .{ .sub_path = input, .data = contents }); const out = try r.output(&.{ "hash-object", "--literally", "-w", "-t", kind, input }); defer r.gpa.free(out); return r.gpa.dupe(u8, std.mem.trimEnd(u8, out, "\n")); }
/// Writes `bytes` as the loose object file for the object ID `hex`, /// replacing any that is there: for objects git would refuse to write. pub fn writeLooseFile(r: *Repo, hex: []const u8, bytes: []const u8) !void { var path_buffer: [128]u8 = undefined; const dir = try std.fmt.bufPrint(&path_buffer, ".git/objects/{s}", .{hex[0..2]}); try r.tmp.dir.createDirPath(r.io, dir); var file_buffer: [128]u8 = undefined; const path = try std.fmt.bufPrint(&file_buffer, ".git/objects/{s}/{s}", .{ hex[0..2], hex[2..] }); r.tmp.dir.deleteFile(r.io, path) catch |err| switch (err) { error.FileNotFound => {}, else => return err, }; try r.tmp.dir.writeFile(r.io, .{ .sub_path = path, .data = bytes }); }
/// A commit writing `contents` to `path`, with `message`. pub fn commitFile(r: *Repo, path: []const u8, contents: []const u8, message: []const u8) !void { try r.tmp.dir.writeFile(r.io, .{ .sub_path = path, .data = contents }); try r.git(&.{ "add", path }); try r.commit(message); }
/// A commit with `message`, a minute after the last, of whatever is /// staged, or of nothing. pub fn commit(r: *Repo, message: []const u8) !void { r.count += 1; var buf: [32]u8 = undefined; const date = try std.fmt.bufPrint(&buf, "@{d} +0000", .{1_790_000_000 + @as(u64, r.count) * 60}); try r.env.put("GIT_AUTHOR_DATE", date); try r.env.put("GIT_COMMITTER_DATE", date); try r.git(&.{ "commit", "-q", "--allow-empty", "-m", message }); }};
/// `raw` in the zlib format (RFC 1950) as one stored, uncompressed DEFLATE/// block (RFC 1951), which the caller frees: the simplest valid stream,/// for writing loose objects git would refuse to, byte by byte.pub fn zlibStored(gpa: Allocator, raw: []const u8) ![]u8 { std.debug.assert(raw.len <= std.math.maxInt(u16)); const len: u16 = @intCast(raw.len); const out = try gpa.alloc(u8, 2 + 5 + raw.len + 4); out[0] = 0x78; out[1] = 0x01; out[2] = 0x01; // the final block, stored std.mem.writeInt(u16, out[3..5], len, .little); std.mem.writeInt(u16, out[5..7], ~len, .little); @memcpy(out[7..][0..raw.len], raw); std.mem.writeInt(u32, out[7 + raw.len ..][0..4], std.hash.Adler32.hash(raw), .big); return out;}
test "the repository's object IDs are as long as its format makes them" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try repo.commit("first"); const head = try repo.output(&.{ "rev-parse", "HEAD" }); defer testing.allocator.free(head); try testing.expectEqual(repo.object_format.hexLength(), std.mem.trimEnd(u8, head, "\n").len);}