Something went wrong. Try again.
A Zig library that reads git repositories without running git, with log, show and diff output byte-identical to git's.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Refspecs: the `[+]<src>:<dst>` mappings between one repository's refs//! and another's that `remote.<name>.fetch` and `remote.<name>.push` hold,//! as git-fetch(1) and git-push(1) describe them, read from their text//! with no I/O and applied to ref names.//!//! The rules for what is well formed were found by asking git to use//! each kind of refspec (`@{upstream}` reads every fetch refspec of the//! remote, and stops at the first it cannot read with `invalid refspec`)://!//! - A leading `+` forces; a leading `^` makes the refspec negative, which//! takes a source alone.//! - The source and destination are split at the last `:`. Either may be//! empty: an empty destination, or none, maps to no ref; an empty//! source on the push side deletes. `:` alone, on the push side, pushes//! the matching branches.//! - A `*` makes a pattern: one in the source and, when there is a//! destination, exactly one in it too, each standing for the same text,//! which may cross `/`. On the fetch side a pattern needs a//! destination.//! - Each side that names a ref is checked by git-check-ref-format(1)'s//! rules with one-level names allowed. A fetch source may instead be a//! whole object ID; a push source may be any revision, so only a//! pattern source is checked.
const std = @import("std");const Allocator = std.mem.Allocator;
const refname = @import("refs/name.zig");
/// Which way a refspec maps: from a remote's refs to ours, or from ours to/// a remote's.pub const Side = enum { fetch, push };
/// One refspec, its strings slices of the text it was read from.pub const Refspec = struct { /// `+`: update even when it is not a fast-forward. force: bool = false, /// `^`: refs matching the source are excluded rather than mapped. negative: bool = false, /// `:` alone, on the push side: every branch that both sides have. matching: bool = false, /// The source and destination each have one `*`. pattern: bool = false, /// The refs mapped from: a ref name, a pattern, an object ID (fetch) /// or a revision (push); empty for none. src: []const u8, /// The refs mapped to; null when the refspec has no `:`. dst: ?[]const u8 = null,
/// Why text is not a refspec: git's `invalid refspec '<text>'`. pub const ParseError = error{InvalidRefspec};
/// Reads `text` as a refspec for `side`. pub fn parse(text: []const u8, side: Side) ParseError!Refspec { var rest = text; var spec: Refspec = .{ .src = "" }; if (rest.len > 0 and rest[0] == '+') { spec.force = true; rest = rest[1..]; } else if (rest.len > 0 and rest[0] == '^') { spec.negative = true; rest = rest[1..]; } if (std.mem.findScalarLast(u8, rest, ':')) |colon| { spec.src = rest[0..colon]; spec.dst = rest[colon + 1 ..]; } else spec.src = rest;
if (spec.negative) { if (spec.dst != null or spec.src.len == 0) return error.InvalidRefspec; spec.pattern = std.mem.findScalar(u8, spec.src, '*') != null; if (!refname.isValid(spec.src, .{ .allow_onelevel = true, .refspec_pattern = true })) return error.InvalidRefspec; return spec; }
if (side == .push and spec.src.len == 0 and spec.dst != null and spec.dst.?.len == 0) { spec.matching = true; return spec; }
const src_stars = std.mem.countScalar(u8, spec.src, '*'); const dst_stars = if (spec.dst) |d| std.mem.countScalar(u8, d, '*') else 0; spec.pattern = src_stars > 0; if (spec.pattern) { const dst = spec.dst orelse ""; if (dst.len == 0) { if (side == .fetch) return error.InvalidRefspec; } else if (dst_stars != 1) return error.InvalidRefspec; } else if (dst_stars != 0) return error.InvalidRefspec;
const options: refname.CheckOptions = .{ .allow_onelevel = true, .refspec_pattern = spec.pattern }; switch (side) { .fetch => if (spec.src.len != 0 and !isWholeId(spec.src) and !refname.isValid(spec.src, options)) return error.InvalidRefspec, .push => { if (spec.src.len == 0 and spec.dst == null) return error.InvalidRefspec; if (spec.pattern and !refname.isValid(spec.src, options)) return error.InvalidRefspec; }, } if (spec.dst) |d| if (d.len != 0 and !refname.isValid(d, options)) return error.InvalidRefspec; return spec; }
/// Whether `name` is one of the refs the source names. pub fn matchesSource(spec: *const Refspec, name: []const u8) bool { if (spec.matching) return false; if (spec.pattern) return capture(spec.src, name) != null; return std.mem.eql(u8, spec.src, name); }
/// Whether `name` is one of the refs the destination names. pub fn matchesDestination(spec: *const Refspec, name: []const u8) bool { const dst = spec.dst orelse return false; if (spec.pattern) return capture(dst, name) != null; return std.mem.eql(u8, dst, name); }
/// The ref `name` is mapped to, which the caller frees, or null if the /// source does not name it or there is no destination. pub fn mapToDestination(spec: *const Refspec, gpa: Allocator, name: []const u8) Allocator.Error!?[]u8 { if (spec.negative or spec.matching) return null; const dst = spec.dst orelse return null; if (dst.len == 0) return null; return substitute(gpa, spec.src, dst, name, spec.pattern); }
/// The ref that maps to `name`, which the caller frees, or null if /// none does. pub fn mapToSource(spec: *const Refspec, gpa: Allocator, name: []const u8) Allocator.Error!?[]u8 { if (spec.negative or spec.matching or spec.src.len == 0) return null; const dst = spec.dst orelse return null; if (dst.len == 0) return null; return substitute(gpa, dst, spec.src, name, spec.pattern); }};
/// What `*` stands for in `name` under `pattern`, or null if `name` does/// not match it.fn capture(pattern: []const u8, name: []const u8) ?[]const u8 { const star = std.mem.findScalar(u8, pattern, '*') orelse return null; const before = pattern[0..star]; const after = pattern[star + 1 ..]; if (name.len < before.len + after.len) return null; if (!std.mem.startsWith(u8, name, before) or !std.mem.endsWith(u8, name, after)) return null; return name[before.len .. name.len - after.len];}
fn substitute(gpa: Allocator, from: []const u8, to: []const u8, name: []const u8, pattern: bool) Allocator.Error!?[]u8 { if (!pattern) { if (!std.mem.eql(u8, from, name)) return null; return try gpa.dupe(u8, to); } const middle = capture(from, name) orelse return null; const star = std.mem.findScalar(u8, to, '*').?; return try std.mem.concat(gpa, u8, &.{ to[0..star], middle, to[star + 1 ..] });}
fn isWholeId(text: []const u8) bool { if (text.len != 40 and text.len != 64) return false; for (text) |c| if (!std.ascii.isHex(c)) return false; return true;}
/// The ref `name` maps to through the first of `specs` whose source names/// it, negative refspecs aside, which the caller frees; null if none/// does. This is how a remote's fetch refspecs say where one of its refs/// is kept here, which git asks without looking at the negative ones.pub fn mapFirst(gpa: Allocator, specs: []const Refspec, name: []const u8) Allocator.Error!?[]u8 { for (specs) |*s| { if (s.negative) continue; if (try s.mapToDestination(gpa, name)) |m| return m; } return null;}
// -- tests -----------------------------------------------------------------------
const testing = std.testing;const fuzz_input = @import("testing/fuzz.zig");
test "refspecs are read as git reads them" { const ok_fetch = [_][]const u8{ "+refs/heads/*:refs/remotes/origin/*", "refs/heads/topic:refs/remotes/origin/topic", "refs/heads/t*:refs/remotes/origin/t*", "refs/heads/*c:refs/remotes/origin/*c", "refs/heads/*:origin/*", ":refs/remotes/origin/topic", "^refs/heads/topic", "topic:refs/remotes/origin/topic", "refs/heads/x", "refs/heads/x:", }; for (ok_fetch) |t| _ = try Refspec.parse(t, .fetch); const bad_fetch = [_][]const u8{ "refs/heads/*:refs/remotes/origin/*/x/*", "refs/heads/*:refs/remotes/origin/x", "refs/heads/*", "refs/heads/to:pic:x", "refs/heads/*:refs/remotes/origin/*?", "refs/heads/*/:refs/remotes/origin/*/", "refs/heads/**:refs/remotes/origin/**", "refs/heads/*:refs/remotes/origin/*:", "^refs/heads/x:refs/y", "^", }; for (bad_fetch) |t| { errdefer std.debug.print("{s}\n", .{t}); try testing.expectError(error.InvalidRefspec, Refspec.parse(t, .fetch)); } const colon = try Refspec.parse(":", .push); try testing.expect(colon.matching); const plus = try Refspec.parse("+refs/heads/topic:refs/for/topic", .push); try testing.expect(plus.force); try testing.expectEqualStrings("refs/for/topic", plus.dst.?); _ = try Refspec.parse("HEAD~1:refs/heads/x", .push); _ = try Refspec.parse("refs/heads/*", .push); _ = try Refspec.parse(":refs/heads/gone", .push);}
test "refspecs map names both ways" { const gpa = testing.allocator; const s = try Refspec.parse("+refs/heads/*:refs/remotes/origin/*", .fetch); const m = (try s.mapToDestination(gpa, "refs/heads/a/b")).?; defer gpa.free(m); try testing.expectEqualStrings("refs/remotes/origin/a/b", m); const back = (try s.mapToSource(gpa, "refs/remotes/origin/x")).?; defer gpa.free(back); try testing.expectEqualStrings("refs/heads/x", back); try testing.expectEqual(null, try s.mapToDestination(gpa, "refs/tags/v1")); const exact = try Refspec.parse("refs/heads/main:refs/remotes/o/main", .fetch); try testing.expect(exact.matchesSource("refs/heads/main")); try testing.expect(!exact.matchesSource("refs/heads/mainx")); const specs = [_]Refspec{ try Refspec.parse("^refs/heads/topic", .fetch), try Refspec.parse("refs/heads/topic:refs/remotes/origin/a", .fetch), try Refspec.parse("refs/heads/topic:refs/remotes/origin/topic", .fetch), }; const first = (try mapFirst(gpa, &specs, "refs/heads/topic")).?; defer gpa.free(first); try testing.expectEqualStrings("refs/remotes/origin/a", first);}
fn fuzzOne(_: void, smith: *testing.Smith) anyerror!void { var buffer: [256]u8 = undefined; const input = fuzz_input.bytes(smith, &buffer); // The refspec, a NUL, and a ref name to map through it. const nul = std.mem.findScalar(u8, input, 0) orelse input.len; const text = input[0..nul]; const name = if (nul == input.len) "refs/heads/main" else input[nul + 1 ..]; for ([_]Side{ .fetch, .push }) |side| { const spec = Refspec.parse(text, side) catch continue; if (spec.pattern) { try testing.expect(std.mem.countScalar(u8, spec.src, '*') == 1); if (spec.dst) |d| if (d.len != 0) try testing.expect(std.mem.countScalar(u8, d, '*') == 1); } if (try spec.mapToDestination(testing.allocator, name)) |m| { defer testing.allocator.free(m); try testing.expect(spec.matchesSource(name)); try testing.expect(spec.matchesDestination(m)); // Mapping back through a pattern recovers the name. if (try spec.mapToSource(testing.allocator, m)) |back| { defer testing.allocator.free(back); if (spec.pattern) try testing.expectEqualStrings(name, back); } } }}
test "fuzz: refspecs" { try testing.fuzz({}, fuzzOne, .{ .corpus = &.{ @embedFile("fuzz/refspec-pattern"), @embedFile("fuzz/refspec-exact"), @embedFile("fuzz/refspec-negative"), } });}