// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Ref names: which are well formed, which are root refs, pseudorefs and //! per-worktree refs, how a name reaches into another worktree, and the //! two pattern dialects commands list refs with. //! //! The rules for a well-formed name are git-check-ref-format(1)'s, as //! `git check-ref-format` can be seen to apply them: //! //! - no component begins with `.` or ends with `.lock`, and none is empty //! (so no leading, trailing or doubled `/`); //! - at least one `/`, unless one-level names are allowed; //! - no `..`, no `@{`, and not the single character `@`; //! - no byte below 0x20, no DEL, and none of space, `~`, `^`, `:`, `?`, //! `[`, `\`, or `*` (one `*` is allowed in a refspec pattern); //! - it does not end with `.`. //! //! Bytes from 0x80 up are allowed whatever they spell, as git allows //! them. `--normalize` drops leading slashes and collapses runs of them //! before checking; a trailing slash is still refused. //! //! Everything here works on bytes the caller has; nothing allocates //! except `normalize`, which writes into a buffer it is given. const std = @import("std"); const wildmatch = @import("../wildmatch.zig"); /// How strictly a name is checked. pub const CheckOptions = struct { /// Allow a name with no `/`, such as `HEAD` (`--allow-onelevel`). allow_onelevel: bool = false, /// Allow one `*`, as a refspec's pattern may have /// (`--refspec-pattern`). refspec_pattern: bool = false, }; /// What makes a name ill formed: the first rule it breaks, scanning from /// the start. pub const Problem = enum { /// The name, or a component of it, is empty: a leading, trailing or /// doubled `/`, or nothing at all. empty_component, /// A component begins with `.`. leading_dot, /// A component ends with `.lock`. lock_suffix, /// The name ends with `.`. trailing_dot, /// Two dots in a row. double_dot, /// `@{`. at_brace, /// The name is `@`. lone_at, /// A control character, or one of space, `~`, `^`, `:`, `?`, `[`, `\`. forbidden_character, /// A `*` where none is allowed, or a second one in a pattern. wildcard, /// No `/`, and one-level names are not allowed. one_level, }; /// The first rule `name` breaks, or null if it is a well-formed ref name. pub fn check(name: []const u8, options: CheckOptions) ?Problem { if (name.len == 0) return .empty_component; if (std.mem.eql(u8, name, "@")) return .lone_at; var stars: usize = 0; var components: usize = 0; var it = std.mem.splitScalar(u8, name, '/'); while (it.next()) |component| { components += 1; if (component.len == 0) return .empty_component; if (component[0] == '.') return .leading_dot; var prev: u8 = 0; for (component) |c| { switch (c) { 0...0x1f, 0x7f, ' ', '~', '^', ':', '?', '[', '\\' => return .forbidden_character, '*' => { stars += 1; if (!options.refspec_pattern or stars > 1) return .wildcard; }, '.' => if (prev == '.') return .double_dot, '{' => if (prev == '@') return .at_brace, else => {}, } prev = c; } if (std.mem.endsWith(u8, component, ".lock")) return .lock_suffix; } if (name[name.len - 1] == '.') return .trailing_dot; if (components < 2 and !options.allow_onelevel) return .one_level; return null; } /// Whether `name` is a well-formed ref name. pub fn isValid(name: []const u8, options: CheckOptions) bool { return check(name, options) == null; } /// `name` as `--normalize` makes it before checking: leading slashes /// dropped and runs of slashes collapsed to one, written into `buffer`, /// which must be at least as long as `name`. pub fn normalize(name: []const u8, buffer: []u8) []u8 { std.debug.assert(buffer.len >= name.len); var n: usize = 0; var prev_slash = true; for (name) |c| { if (c == '/') { if (prev_slash) continue; prev_slash = true; } else prev_slash = false; buffer[n] = c; n += 1; } return buffer[0..n]; } /// Whether `name` has the shape of a root ref, a ref outside `refs/`: /// nothing but upper-case letters and underscores. `show-ref --verify` /// accepts such a name as well as one beginning `refs/`. pub fn hasRootRefSyntax(name: []const u8) bool { if (name.len == 0) return false; for (name) |c| if (!(std.ascii.isUpper(c) or c == '_')) return false; return true; } /// The root refs gitglossary(7) lists as not following the rule that a /// root ref is `HEAD` or ends in `_HEAD`; the list is closed. pub const irregular_root_refs = [_][]const u8{ "AUTO_MERGE", "BISECT_EXPECTED_REV", "NOTES_MERGE_PARTIAL", "NOTES_MERGE_REF", "MERGE_AUTOSTASH", }; /// Whether `name` is a root ref: of root ref syntax and either `HEAD`, /// ending in `_HEAD`, or one of `irregular_root_refs`. Listing refs leaves /// these out unless asked for them. pub fn isRootRef(name: []const u8) bool { if (!hasRootRefSyntax(name)) return false; if (std.mem.eql(u8, name, "HEAD") or std.mem.endsWith(u8, name, "_HEAD")) return true; for (irregular_root_refs) |r| if (std.mem.eql(u8, name, r)) return true; return false; } /// Whether `name` is one of the pseudorefs gitglossary(7) names, /// `FETCH_HEAD` and `MERGE_HEAD`: refs that may hold several object IDs /// with more besides, kept as files in the git directory whatever the ref /// storage, and read only for the first object ID they hold. pub fn isPseudoref(name: []const u8) bool { return std.mem.eql(u8, name, "FETCH_HEAD") or std.mem.eql(u8, name, "MERGE_HEAD"); } /// The prefixes under `refs/` whose refs each worktree has its own of. pub const per_worktree_prefixes = [_][]const u8{ "refs/bisect/", "refs/worktree/", "refs/rewritten/" }; /// Whether each worktree has its own ref of this name: everything outside /// `refs/` (`HEAD` and the other root refs), and what is under the /// prefixes in `per_worktree_prefixes`. Every other ref is shared by all /// worktrees. The name must not have a worktree prefix (see `Scoped`). pub fn isPerWorktree(name: []const u8) bool { if (!std.mem.startsWith(u8, name, "refs/")) return true; for (per_worktree_prefixes) |p| if (std.mem.startsWith(u8, name, p)) return true; return false; } /// A ref name with any worktree prefix taken off: which worktree's refs /// it names, and the name within them. pub const Scoped = struct { /// Whose refs. worktree: Worktree, /// The name within that worktree's refs. name: []const u8, /// Whose refs a name reaches. pub const Worktree = union(enum) { /// The worktree the repository was opened in. current, /// `main-worktree/`: the main worktree's. main, /// `worktrees//`: the linked worktree of that name, as it is /// named under the common directory's `worktrees/`. linked: []const u8, }; /// Splits `main-worktree/` and `worktrees//` /// into the worktree and the name; any other name is the current /// worktree's. pub fn parse(full: []const u8) Scoped { if (std.mem.cutPrefix(u8, full, "main-worktree/")) |rest| { if (rest.len != 0) return .{ .worktree = .main, .name = rest }; } if (std.mem.cutPrefix(u8, full, "worktrees/")) |rest| { if (std.mem.findScalar(u8, rest, '/')) |slash| { if (slash != 0 and slash + 1 < rest.len) return .{ .worktree = .{ .linked = rest[0..slash] }, .name = rest[slash + 1 ..] }; } } return .{ .worktree = .current, .name = full }; } }; /// Whether a name read from `packed-refs` is safe to treat as a ref at /// all; git stops ("packed refname is dangerous") at one that is not. A /// name under `refs/` must be a clean path — no empty, `.` or `..` /// component and no trailing slash — though it may break the other rules, /// which only make it a broken name to pass over; any other name must be /// well formed as a one-level name may be. pub fn isSafe(name: []const u8) bool { if (std.mem.cutPrefix(u8, name, "refs/")) |rest| { if (rest.len == 0) return false; var it = std.mem.splitScalar(u8, rest, '/'); while (it.next()) |c| { if (c.len == 0 or std.mem.eql(u8, c, ".") or std.mem.eql(u8, c, "..")) return false; } return true; } return isValid(name, .{ .allow_onelevel = true }); } /// Whether `for-each-ref` shows `name` for `pattern`: a pattern with a /// wildcard is a glob matched with wildmatch's pathname rule, so `*` and /// `?` stop at `/` while `**` crosses it; one without is a literal that /// matches the whole name, or its start up to a `/`. /// /// (`?` does not match `/` here: `refs/heads/a?b/c` matches nothing of /// `refs/heads/a/b/c` for `git for-each-ref`.) pub fn matchesForEachRefPattern(pattern: []const u8, name: []const u8) bool { if (wildmatch.hasWildcards(pattern)) return wildmatch.match(pattern, name, .{ .pathname = true }); if (!std.mem.startsWith(u8, name, pattern)) return false; return name.len == pattern.len or name[pattern.len] == '/' or (pattern.len != 0 and pattern[pattern.len - 1] == '/'); } /// Whether `show-ref` shows `name` for `pattern`: the pattern matches /// whole components at the end of the name, so `main` matches /// `refs/heads/main` and `refs/remotes/origin/main` but not /// `refs/heads/mymain`. There are no wildcards. pub fn matchesShowRefPattern(pattern: []const u8, name: []const u8) bool { if (!std.mem.endsWith(u8, name, pattern)) return false; if (pattern.len == name.len) return true; return name[name.len - pattern.len - 1] == '/'; } /// The ways a short name is tried as a full one, in the order /// gitrevisions(7) gives: as it is, then under `refs/`, `refs/tags/`, /// `refs/heads/`, `refs/remotes/`, and as a remote's `HEAD`. pub const lookup_rules = [_]struct { prefix: []const u8, suffix: []const u8 }{ .{ .prefix = "", .suffix = "" }, .{ .prefix = "refs/", .suffix = "" }, .{ .prefix = "refs/tags/", .suffix = "" }, .{ .prefix = "refs/heads/", .suffix = "" }, .{ .prefix = "refs/remotes/", .suffix = "" }, .{ .prefix = "refs/remotes/", .suffix = "/HEAD" }, }; /// The short name `full` has under lookup rule `rule`, or null if the /// rule cannot produce it. pub fn shortUnderRule(full: []const u8, rule: usize) ?[]const u8 { const r = lookup_rules[rule]; if (!std.mem.startsWith(u8, full, r.prefix) or !std.mem.endsWith(u8, full, r.suffix)) return null; if (full.len <= r.prefix.len + r.suffix.len) return null; return full[r.prefix.len .. full.len - r.suffix.len]; } /// Strips `n` components from the front of `name` as /// `%(refname:lstrip=n)` does: for a negative `n`, as many as leave `-n`. /// Too few components give an empty name for a positive `n` and the whole /// name for a negative one. pub fn lstrip(name: []const u8, n: i64) []const u8 { const count = std.mem.countScalar(u8, name, '/') + 1; var strip: u64 = undefined; if (n >= 0) { strip = @intCast(n); if (strip >= count) return if (strip == 0) name else ""; } else { const keep: u64 = @abs(n); if (keep >= count) return name; strip = count - keep; } var rest = name; for (0..strip) |_| rest = rest[(std.mem.findScalar(u8, rest, '/') orelse return "") + 1 ..]; return rest; } /// Strips `n` components from the back of `name` as /// `%(refname:rstrip=n)` does, with `lstrip`'s rules for negative and /// excessive counts. pub fn rstrip(name: []const u8, n: i64) []const u8 { const count = std.mem.countScalar(u8, name, '/') + 1; var strip: u64 = undefined; if (n >= 0) { strip = @intCast(n); if (strip >= count) return if (strip == 0) name else ""; } else { const keep: u64 = @abs(n); if (keep >= count) return name; strip = count - keep; } var rest = name; for (0..strip) |_| rest = rest[0 .. std.mem.findScalarLast(u8, rest, '/') orelse return ""]; return rest; } // -- tests ----------------------------------------------------------------------- const testing = std.testing; const fuzz_input = @import("../testing/fuzz.zig"); test "names are checked by git-check-ref-format's rules" { const one: CheckOptions = .{ .allow_onelevel = true }; const pat: CheckOptions = .{ .refspec_pattern = true }; try testing.expectEqual(null, check("refs/heads/main", .{})); try testing.expectEqual(.one_level, check("main", .{})); try testing.expectEqual(null, check("HEAD", one)); try testing.expectEqual(.lone_at, check("@", one)); try testing.expectEqual(null, check("refs/@", .{})); try testing.expectEqual(.empty_component, check("foo//bar", .{})); try testing.expectEqual(.empty_component, check("foo/", .{})); try testing.expectEqual(.leading_dot, check("foo/.bar", .{})); try testing.expectEqual(.lock_suffix, check("foo/bar.lock", .{})); try testing.expectEqual(.trailing_dot, check("foo/bar.", .{})); try testing.expectEqual(.double_dot, check("foo/a..b", .{})); try testing.expectEqual(.at_brace, check("foo/a@{b", .{})); try testing.expectEqual(null, check("foo/a{b", .{})); try testing.expectEqual(.forbidden_character, check("foo/a b", .{})); try testing.expectEqual(.forbidden_character, check("foo/a\x7fb", .{})); try testing.expectEqual(.wildcard, check("foo/*", .{})); try testing.expectEqual(null, check("foo/*", pat)); try testing.expectEqual(.wildcard, check("foo/*/*", pat)); try testing.expectEqual(null, check("foo/\xc3\xa9", .{})); try testing.expectEqual(null, check("foo/a]b", .{})); } test "normalizing drops leading slashes and collapses runs" { var buffer: [32]u8 = undefined; try testing.expectEqualStrings("foo/bar", normalize("//foo///bar", &buffer)); try testing.expectEqualStrings("foo/", normalize("foo/", &buffer)); } test "root refs, pseudorefs and per-worktree refs are told apart" { try testing.expect(isRootRef("HEAD")); try testing.expect(isRootRef("ORIG_HEAD")); try testing.expect(isRootRef("MERGE_AUTOSTASH")); try testing.expect(!isRootRef("FOO")); try testing.expect(hasRootRefSyntax("FOO")); try testing.expect(!hasRootRefSyntax("FOO1")); try testing.expect(isPseudoref("FETCH_HEAD")); try testing.expect(isPerWorktree("HEAD")); try testing.expect(isPerWorktree("refs/bisect/good")); try testing.expect(!isPerWorktree("refs/heads/main")); const s = Scoped.parse("worktrees/wt/refs/bisect/bad"); try testing.expectEqualStrings("wt", s.worktree.linked); try testing.expectEqualStrings("refs/bisect/bad", s.name); try testing.expectEqual(.main, Scoped.parse("main-worktree/HEAD").worktree); try testing.expectEqual(.current, Scoped.parse("worktrees/x").worktree); } test "packed names are safe or dangerous as git finds them" { try testing.expect(isSafe("refs/heads/a..b")); try testing.expect(!isSafe("refs/heads/../x")); try testing.expect(!isSafe("refs/heads/x/")); try testing.expect(!isSafe("refs/")); try testing.expect(isSafe("foo")); try testing.expect(isSafe("x/y")); try testing.expect(!isSafe(" refs/heads/main")); try testing.expect(!isSafe("x.lock")); } test "for-each-ref and show-ref patterns match as git's do" { try testing.expect(matchesForEachRefPattern("refs/heads", "refs/heads/main")); try testing.expect(matchesForEachRefPattern("refs/heads/", "refs/heads/main")); try testing.expect(!matchesForEachRefPattern("refs/hea", "refs/heads/main")); try testing.expect(matchesForEachRefPattern("refs/heads/*", "refs/heads/main")); try testing.expect(!matchesForEachRefPattern("refs/heads/*", "refs/heads/a/b")); try testing.expect(!matchesForEachRefPattern("refs/heads/a?b", "refs/heads/a/b")); try testing.expect(matchesForEachRefPattern("refs/**/c", "refs/heads/a/b/c")); try testing.expect(matchesShowRefPattern("main", "refs/heads/main")); try testing.expect(!matchesShowRefPattern("main", "refs/heads/mymain")); try testing.expect(matchesShowRefPattern("heads/main", "refs/heads/main")); } test "components are stripped as %(refname:lstrip) and rstrip do" { try testing.expectEqualStrings("foo", lstrip("refs/tags/foo", 2)); try testing.expectEqualStrings("tags/foo", lstrip("refs/tags/foo", -2)); try testing.expectEqualStrings("", lstrip("refs/tags/foo", 5)); try testing.expectEqualStrings("refs/tags/foo", lstrip("refs/tags/foo", -5)); try testing.expectEqualStrings("refs", rstrip("refs/tags/foo", 2)); try testing.expectEqualStrings("refs", rstrip("refs/tags/foo", -1)); try testing.expectEqualStrings("refs/tags/foo", rstrip("refs/tags/foo", 0)); } fn fuzzCheck(_: void, smith: *testing.Smith) anyerror!void { var buffer: [256]u8 = undefined; const name = @import("../testing/fuzz.zig").bytes(smith, &buffer); var normalized: [256]u8 = undefined; for ([_]bool{ false, true }) |onelevel| for ([_]bool{ false, true }) |pattern| { const options: CheckOptions = .{ .allow_onelevel = onelevel, .refspec_pattern = pattern }; if (check(name, options) == null) { // A valid name stays valid when normalized, and is its own // normal form. try testing.expectEqualStrings(name, normalize(name, normalized[0..name.len])); } }; _ = isSafe(name); _ = Scoped.parse(name); _ = lstrip(name, 2); _ = rstrip(name, -1); } test "fuzz: ref names" { try fuzz_input.fuzz({}, fuzzCheck, .{ .corpus = &.{ @embedFile("../fuzz/refname"), "refs/heads/main", "refs/heads/a..b", "//foo/bar*", "@", "refs/heads/x.lock", } }); }