diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aed79c9..6cd5777 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,18 @@ on: jobs: build: strategy: + fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + # macos + 0.15.2 is omitted: zig 0.15.2 cannot link against the + # macOS 26 SDK on current runner images (and current Macs). + # 0.16 support comes via src/compat.zig. + include: + - os: ubuntu-latest + zig: "0.15.2" + - os: ubuntu-latest + zig: "0.16.0" + - os: macos-latest + zig: "0.16.0" runs-on: ${{ matrix.os }} @@ -19,7 +29,7 @@ jobs: - uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: ${{ matrix.zig }} - name: Install libgit2 (Linux) if: runner.os == 'Linux' @@ -41,3 +51,6 @@ jobs: ./zig-out/bin/nit status ./zig-out/bin/nit log -n 5 ./zig-out/bin/nit show + + - name: Conformance tests + run: ./tests/conformance.sh ./zig-out/bin/nit diff --git a/README.md b/README.md index 9e4d447..a3c1eb2 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ We ran [experiments](experiments/) to validate our choice of 1 context line (vs ## Install -Requires [libgit2](https://libgit2.org/) and [zig](https://ziglang.org/) 0.14+. +Requires [libgit2](https://libgit2.org/) and [zig](https://ziglang.org/) 0.15.2 or 0.16.0. On recent macOS (26+ SDK) use 0.16.0 - zig 0.15.2 cannot link against that SDK. ```sh # From source diff --git a/build.zig.zon b/build.zig.zon index d665392..4fedb1d 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,7 +2,7 @@ .name = .nit, .version = "0.1.0", .fingerprint = 0x5e5f5af3d86f98f2, - .minimum_zig_version = "0.14.0", + .minimum_zig_version = "0.15.2", .paths = .{ "build.zig", diff --git a/src/cli.zig b/src/cli.zig index f7ca5da..ddc99a8 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat.zig"); const git = @import("git.zig"); const status = @import("cmd/status.zig"); const log = @import("cmd/log.zig"); @@ -26,15 +27,14 @@ const usage = \\ ; -pub fn run() !void { +pub fn run(init: compat.ProcessInit) !void { color.init(); var buf: [8192]u8 = undefined; - var file_writer = std.fs.File.stdout().writer(&buf); + var file_writer = compat.stdoutWriter(init, &buf); const w = &file_writer.interface; - const args = try std.process.argsAlloc(std.heap.page_allocator); - defer std.process.argsFree(std.heap.page_allocator, args); + const args = try compat.argsSlice(init); if (args.len < 2) { try w.writeAll(usage); @@ -163,7 +163,5 @@ fn passthrough(args: []const [:0]const u8) !void { argv[args.len] = null; const argv_ptr: [*:null]const ?[*:0]const u8 = @ptrCast(argv.ptr); - const envp: [*:null]const ?[*:0]const u8 = @ptrCast(std.c.environ); - - return std.posix.execvpeZ("git", argv_ptr, envp); + return compat.execGit(argv_ptr); } diff --git a/src/color.zig b/src/color.zig index f8b4189..c89ef50 100644 --- a/src/color.zig +++ b/src/color.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat.zig"); // Default ANSI colors (respects terminal theme) const defaults = struct { @@ -54,7 +55,7 @@ fn makeEscape(sgr: []const u8) []const u8 { } pub fn init() void { - const env = std.posix.getenv("NIT_COLORS") orelse return; + const env = compat.getenv("NIT_COLORS") orelse return; // Parse "key=value:key=value:..." var iter = std.mem.splitScalar(u8, env, ':'); @@ -88,5 +89,5 @@ pub fn init() void { } pub fn isTty() bool { - return std.posix.isatty(std.fs.File.stdout().handle); + return compat.stdoutIsTty(); } diff --git a/src/compat.zig b/src/compat.zig new file mode 100644 index 0000000..8e461d2 --- /dev/null +++ b/src/compat.zig @@ -0,0 +1,57 @@ +//! Zig version compatibility layer. +//! +//! nit supports both Zig 0.15.x and 0.16.x (where the std I/O and process +//! APIs changed shape). Everything version-sensitive lives here so the rest +//! of the codebase can stay on the shared std.Io.Writer interface, which is +//! identical across both versions. +const std = @import("std"); + +/// Zig 0.16 introduced std.process.Init and moved File I/O behind std.Io. +pub const is_modern = @hasDecl(std.process, "Init"); + +/// On 0.16 main receives std.process.Init (which carries the Io instance +/// and args); on 0.15 main takes no arguments and we reach for globals. +pub const ProcessInit = if (is_modern) std.process.Init else void; + +pub const StdoutWriter = if (is_modern) std.Io.File.Writer else std.fs.File.Writer; + +pub fn stdoutWriter(init: ProcessInit, buffer: []u8) StdoutWriter { + if (is_modern) { + // Streaming, not positional: positional mode pwrites from offset 0, + // which overwrites earlier output when stdout is redirected to a file. + return std.Io.File.stdout().writerStreaming(init.io, buffer); + } else { + return std.fs.File.stdout().writer(buffer); + } +} + +/// Argument slices live for the whole process (nit is a short-lived CLI): +/// arena-backed on 0.16, page-allocated and never freed on 0.15. +pub fn argsSlice(init: ProcessInit) ![]const [:0]const u8 { + if (is_modern) { + return init.minimal.args.toSlice(init.arena.allocator()); + } else { + const raw = try std.process.argsAlloc(std.heap.page_allocator); + return @ptrCast(raw); + } +} + +/// libc getenv: identical on both versions (std.posix.getenv was removed in 0.16). +pub fn getenv(name: [*:0]const u8) ?[]const u8 { + const val = std.c.getenv(name) orelse return null; + return std.mem.span(val); +} + +pub fn stdoutIsTty() bool { + return std.c.isatty(std.posix.STDOUT_FILENO) == 1; +} + +// std.posix.execvpeZ was removed in 0.16; libc execvp (which inherits the +// process environment) covers both versions since we always link libc. +extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; + +/// Replace the current process with `git`. Only returns on failure. +pub fn execGit(argv: [*:null]const ?[*:0]const u8) error{ExecFailed} { + _ = execvp("git", argv); + return error.ExecFailed; +} diff --git a/src/git.zig b/src/git.zig index 9649a0a..a428c66 100644 --- a/src/git.zig +++ b/src/git.zig @@ -9,13 +9,9 @@ pub const Repository = struct { pub fn openFromCwd() !Repository { var repo: ?*c.git_repository = null; - const cwd_dir = std.fs.cwd(); - var buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try cwd_dir.realpath(".", &buf); - const path_z = try std.heap.page_allocator.dupeZ(u8, path); - defer std.heap.page_allocator.free(path_z); - - try check(c.git_repository_open_ext(&repo, path_z, 0, null)); + // libgit2 resolves the relative path and discovers upward itself; + // no need to realpath the cwd first. + try check(c.git_repository_open_ext(&repo, ".", 0, null)); return .{ .repo = repo.? }; } diff --git a/src/main.zig b/src/main.zig index fabde75..bab1a72 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,14 @@ const cli = @import("cli.zig"); +const compat = @import("compat.zig"); -pub fn main() !void { - try cli.run(); +// Zig 0.16 passes std.process.Init to main; 0.15 requires a zero-arg main. +// compat.ProcessInit keeps both signatures valid on both versions. +pub const main = if (compat.is_modern) mainModern else mainLegacy; + +fn mainModern(init: compat.ProcessInit) !void { + try cli.run(init); +} + +fn mainLegacy() !void { + try cli.run({}); } diff --git a/tests/conformance.sh b/tests/conformance.sh index b636617..74894ab 100755 --- a/tests/conformance.sh +++ b/tests/conformance.sh @@ -22,11 +22,22 @@ fi # Resolve to absolute path NIT="$(cd "$(dirname "$NIT")" && pwd)/$(basename "$NIT")" +# Run a command under a pseudo-TTY. BSD/macOS script takes the command as +# positional args; util-linux script needs -c (and %q-quoting, since color +# values contain semicolons). +if script -qec true /dev/null >/dev/null 2>&1; then + run_tty() { script -qec "$(printf '%q ' "$@")" /dev/null; } +else + run_tty() { script -q /dev/null "$@"; } +fi + TMPDIR=$(mktemp -d -t nit-conformance-XXXXXX) trap "rm -rf $TMPDIR" EXIT cd "$TMPDIR" -git init -q +# -b main: the merge-commit tests checkout main by name, so don't depend +# on the host's init.defaultBranch +git init -q -b main git config user.email "test@test.com" git config user.name "Test User" @@ -731,13 +742,13 @@ check "show HEAD:binary.dat matches git" \ echo "" echo "NIT_COLORS:" -# On macOS, script -q /dev/null forces a pseudo-TTY +# On macOS, run_tty forces a pseudo-TTY # Verify that NIT_COLORS changes output when running in a TTY if command -v script >/dev/null 2>&1; then # Without NIT_COLORS: default colors in TTY - default_out=$(script -q /dev/null $NIT show -H 2>&1 | cat -v | head -3) + default_out=$(run_tty $NIT show -H 2>&1 | cat -v | head -3) # With NIT_COLORS: custom hash color (magenta = 35) - custom_out=$(script -q /dev/null env NIT_COLORS="hash=35" $NIT show -H 2>&1 | cat -v | head -3) + custom_out=$(run_tty env NIT_COLORS="hash=35" $NIT show -H 2>&1 | cat -v | head -3) if [ "$default_out" != "$custom_out" ]; then PASS=$((PASS + 1)) @@ -775,7 +786,7 @@ if command -v script >/dev/null 2>&1; then "NIT_COLORS='hash=38;2;255;165;0' $NIT log -n 1 2>&1" # Verify true-color actually produces escape sequence in TTY - truecolor_out=$(script -q /dev/null env NIT_COLORS="hash=38;2;255;0;0" $NIT show -H 2>&1 | cat -v | head -1) + truecolor_out=$(run_tty env NIT_COLORS="hash=38;2;255;0;0" $NIT show -H 2>&1 | cat -v | head -1) if echo "$truecolor_out" | grep -q '38;2;255;0;0'; then PASS=$((PASS + 1)) echo " PASS: NIT_COLORS true-color escape in TTY output" @@ -797,8 +808,8 @@ if command -v script >/dev/null 2>&1; then "NIT_COLORS='boguskey=32' $NIT log -n 1 2>&1" # Duplicate keys (last wins) - verify output changes with second value - dup_out1=$(script -q /dev/null env NIT_COLORS="hash=35" $NIT show -H 2>&1 | cat -v | head -1) - dup_out2=$(script -q /dev/null env NIT_COLORS="hash=33:hash=35" $NIT show -H 2>&1 | cat -v | head -1) + dup_out1=$(run_tty env NIT_COLORS="hash=35" $NIT show -H 2>&1 | cat -v | head -1) + dup_out2=$(run_tty env NIT_COLORS="hash=33:hash=35" $NIT show -H 2>&1 | cat -v | head -1) if [ "$dup_out1" = "$dup_out2" ]; then PASS=$((PASS + 1)) echo " PASS: NIT_COLORS duplicate keys (last wins)" @@ -836,7 +847,7 @@ if command -v script >/dev/null 2>&1; then "NIT_COLORS='add=31:del=32:hunk=33:context=34:staged=35:unstaged=36:hash=37:date=38:add=39:del=40:hunk=41:context=42' $NIT log -n 1 2>&1" # NIT_COLORS affects show -H (color slot: hash, date used in show) - custom_show=$(script -q /dev/null env NIT_COLORS="hash=35" $NIT show -H 2>&1 | cat -v | head -1) + custom_show=$(run_tty env NIT_COLORS="hash=35" $NIT show -H 2>&1 | cat -v | head -1) if [ "$default_out" != "$custom_show" ] || echo "$custom_show" | grep -q '35m'; then PASS=$((PASS + 1)) echo " PASS: NIT_COLORS affects show -H" @@ -847,8 +858,8 @@ if command -v script >/dev/null 2>&1; then fi # NIT_COLORS affects diff -H (color slot: add, del, hunk, context) - default_diff=$(script -q /dev/null $NIT diff -H 2>&1 | cat -v | head -3) - custom_diff=$(script -q /dev/null env NIT_COLORS="add=35:del=36" $NIT diff -H 2>&1 | cat -v | head -3) + default_diff=$(run_tty $NIT diff -H 2>&1 | cat -v | head -3) + custom_diff=$(run_tty env NIT_COLORS="add=35:del=36" $NIT diff -H 2>&1 | cat -v | head -3) if [ "$default_diff" != "$custom_diff" ]; then PASS=$((PASS + 1)) echo " PASS: NIT_COLORS affects diff -H" @@ -859,8 +870,8 @@ if command -v script >/dev/null 2>&1; then fi # NIT_COLORS affects log -H (color slot: hash, date) - default_log=$(script -q /dev/null $NIT log -H -n 1 2>&1 | cat -v | head -1) - custom_log=$(script -q /dev/null env NIT_COLORS="hash=35:date=36" $NIT log -H -n 1 2>&1 | cat -v | head -1) + default_log=$(run_tty $NIT log -H -n 1 2>&1 | cat -v | head -1) + custom_log=$(run_tty env NIT_COLORS="hash=35:date=36" $NIT log -H -n 1 2>&1 | cat -v | head -1) if [ "$default_log" != "$custom_log" ]; then PASS=$((PASS + 1)) echo " PASS: NIT_COLORS affects log -H" @@ -871,8 +882,8 @@ if command -v script >/dev/null 2>&1; then fi # NIT_COLORS affects status -H (color slot: staged, unstaged) - default_status=$(script -q /dev/null $NIT status -H 2>&1 | cat -v) - custom_status=$(script -q /dev/null env NIT_COLORS="staged=35:unstaged=36" $NIT status -H 2>&1 | cat -v) + default_status=$(run_tty $NIT status -H 2>&1 | cat -v) + custom_status=$(run_tty env NIT_COLORS="staged=35:unstaged=36" $NIT status -H 2>&1 | cat -v) if [ "$default_status" != "$custom_status" ]; then PASS=$((PASS + 1)) echo " PASS: NIT_COLORS affects status -H"