-- SPDX-FileCopyrightText: © 2022—2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. --[==[ Pure-Lua git repository reader. Parses `.git` directory structures directly, avoiding the need to spawn `git` processes: loose and packed objects (pack `.idx` v2, OFS/REF delta resolution), refs (loose, packed, symbolic), HEAD, the staging index, commit and tree objects. Works on bare repositories and worktrees alike — every function takes an explicit `git_dir`. Read-only. Higher layers build on this core: `git.prompt` (shell prompt status) and `git.pack` (packfile generation for serving). ]==] local std = require("std") local zlib = require("zlib") local crypto = require("crypto") local bit = require("bit") local band, bor, lshift, rshift = bit.band, bit.bor, bit.lshift, bit.rshift --------------------------------------------------------------------------- -- Caching state (module-level, self-invalidating by mtime) --------------------------------------------------------------------------- local packed_refs_cache = {} -- git_dir → {mtime, refs} local pack_cache = {} -- git_dir → {mtime, packs} --------------------------------------------------------------------------- -- Binary helpers --------------------------------------------------------------------------- local read_u32_be = function(data, offset) local a, b, c, d = string.byte(data, offset, offset + 3) return a * 16777216 + b * 65536 + c * 256 + d end local read_u16_be = function(data, offset) local a, b = string.byte(data, offset, offset + 1) return a * 256 + b end local read_binary_file = function(path) local f = io.open(path, "rb") if not f then return nil end local data = f:read("*a") f:close() return data end local read_binary_at = function(path, offset, size) local f = io.open(path, "rb") if not f then return nil end f:seek("set", offset) local data = f:read(size) f:close() return data end local stat_mtime = function(path) local st = std.fs.stat(path) if st then return st.mtime end return nil end --------------------------------------------------------------------------- -- Git directory discovery --------------------------------------------------------------------------- ---! Discover the .git directory by walking up from a starting path ---@ find_git_dir(`start_path`{.str .opt}) -> `git_dir`{.str .or_nil}, `worktree`{.str .or_nil} --[===[ Walks the directory tree upward from `start_path` (defaults to cwd) looking for a `.git` directory or gitdir file (worktrees/submodules). Returns the resolved git directory path and the worktree root, or nil if not inside a git repository. ]===] local find_git_dir = function(start_path) local path = start_path or std.fs.cwd() if not path then return nil end while true do local git_path = path .. "/.git" local st = std.fs.stat(git_path) if st then if st.mode == "d" then return git_path, path elseif st.mode == "f" then local content = read_binary_file(git_path) if content then local target = content:match("gitdir:%s*(.-)%s*$") if target then if target:sub(1, 1) ~= "/" then target = path .. "/" .. target end return target, path end end end end if path == "/" or path == "" then return nil end path = path:match("^(.*)/[^/]+$") or "/" end end --------------------------------------------------------------------------- -- HEAD + ref resolution --------------------------------------------------------------------------- local parse_packed_refs = function(git_dir) local pr_path = git_dir .. "/packed-refs" local mtime = stat_mtime(pr_path) local cached = packed_refs_cache[git_dir] if cached and cached.mtime == mtime then return cached.refs end local data = read_binary_file(pr_path) if not data then packed_refs_cache[git_dir] = { mtime = mtime, refs = {} } return {} end local refs = {} local last_ref = nil for line in data:gmatch("[^\n]+") do if line:sub(1, 1) == "#" then -- comment, skip elseif line:sub(1, 1) == "^" then -- peeled tag line if last_ref then refs[last_ref .. "^{}"] = line:sub(2):match("^%s*(%x+)") end else local sha, ref = line:match("^(%x+)%s+(.+)$") if sha and ref then refs[ref] = sha last_ref = ref end end end packed_refs_cache[git_dir] = { mtime = mtime, refs = refs } return refs end ---! Resolve a ref path (e.g. `refs/heads/main`) to a SHA, following symbolic refs and packed-refs ---@ resolve_ref(`git_dir`{.str}, `ref_path`{.str}) -> `sha`{.str .or_nil} local resolve_ref resolve_ref = function(git_dir, ref_path, depth) depth = depth or 0 if depth > 10 then return nil end -- Try loose ref first local loose_path = git_dir .. "/" .. ref_path local data = read_binary_file(loose_path) if data then data = data:match("^%s*(.-)%s*$") if data:match("^ref:%s+") then local target = data:match("^ref:%s+(.+)$") return resolve_ref(git_dir, target, depth + 1) end if data:match("^%x+$") then return data end end -- Try packed-refs local packed = parse_packed_refs(git_dir) local sha = packed[ref_path] if sha then return sha end return nil end ---! Read HEAD of a repository ---@ read_head(`git_dir`{.str}) -> `head`{.tbl .or_nil} --[===[ Returns `{ type = "branch", name = short_name, ref = full_ref }` for a symbolic HEAD, or `{ type = "detached", sha = sha }` for a detached one. ]===] local read_head = function(git_dir) local data = read_binary_file(git_dir .. "/HEAD") if not data then return nil end data = data:match("^%s*(.-)%s*$") if data:match("^ref:%s+") then local ref = data:match("^ref:%s+(.+)$") local short = ref:match("refs/heads/(.+)$") or ref return { type = "branch", name = short, ref = ref } end if data:match("^%x+$") then return { type = "detached", sha = data } end return nil end --------------------------------------------------------------------------- -- Config parsing --------------------------------------------------------------------------- local parse_config = function(git_dir) local data = read_binary_file(git_dir .. "/config") if not data then return {} end local config = {} local current_section = nil for line in data:gmatch("[^\n]+") do line = line:match("^%s*(.-)%s*$") if line == "" or line:sub(1, 1) == "#" or line:sub(1, 1) == ";" then -- skip else local section = line:match("^%[(.+)%]$") if section then section = section:gsub('"', ""):gsub("%s+", " "):match("^(.-)%s*$") current_section = section:lower() if not config[current_section] then config[current_section] = {} end elseif current_section then local key, value = line:match("^(%S+)%s*=%s*(.+)$") if key and value then config[current_section][key:lower()] = value end end end end return config end local get_tracking_branch = function(git_dir, branch_name) local config = parse_config(git_dir) local section = config["branch " .. branch_name] if not section then return nil, nil end local remote = section.remote local merge = section.merge if not remote or not merge then return nil, nil end local remote_branch = merge:match("refs/heads/(.+)$") or merge local tracking_ref = "refs/remotes/" .. remote .. "/" .. remote_branch local display = remote .. "/" .. remote_branch return tracking_ref, display end --------------------------------------------------------------------------- -- Object reading — loose --------------------------------------------------------------------------- local read_loose_object = function(git_dir, sha_hex) local prefix = sha_hex:sub(1, 2) local suffix = sha_hex:sub(3) local path = git_dir .. "/objects/" .. prefix .. "/" .. suffix local data = read_binary_file(path) if not data then return nil end local decompressed = zlib.decompress(data) if not decompressed then return nil end local header_end = decompressed:find("\0") if not header_end then return nil end local header = decompressed:sub(1, header_end - 1) local obj_type, obj_size = header:match("^(%a+) (%d+)$") if not obj_type then return nil end local content = decompressed:sub(header_end + 1) return { type = obj_type, size = tonumber(obj_size), content = content } end --------------------------------------------------------------------------- -- Pack file internals --------------------------------------------------------------------------- local find_in_pack_index = function(idx_data, sha_bin) -- v2 index format local magic = read_u32_be(idx_data, 1) if magic ~= 0xff744f63 then return nil -- not v2 end -- version = read_u32_be(idx_data, 5) -- should be 2 -- Fanout table: 256 entries of 4 bytes each, starting at offset 8+1 (1-indexed) local fanout_offset = 9 -- byte 9 in 1-indexed = offset 8 in 0-indexed local first_byte = string.byte(sha_bin, 1) local lo = 0 if first_byte > 0 then lo = read_u32_be(idx_data, fanout_offset + (first_byte - 1) * 4) end local hi = read_u32_be(idx_data, fanout_offset + first_byte * 4) local total = read_u32_be(idx_data, fanout_offset + 255 * 4) if hi == lo then return nil -- no objects with this prefix end -- SHA table starts after fanout: offset 8 + 256*4 = 1032 (0-indexed), 1033 (1-indexed) local sha_table_offset = 1033 -- Binary search in SHA table local low, high = lo, hi - 1 while low <= high do local mid = math.floor((low + high) / 2) local sha_offset = sha_table_offset + mid * 20 local entry_sha = idx_data:sub(sha_offset, sha_offset + 19) if entry_sha == sha_bin then -- Found it. Get the pack offset. -- CRC table: after SHA table -- Offset table: after CRC table local offset_table_start = sha_table_offset + total * 20 + total * 4 local off_pos = offset_table_start + mid * 4 local pack_offset = read_u32_be(idx_data, off_pos) -- Check MSB for large offset if band(pack_offset, 0x80000000) ~= 0 then local large_offset_table = offset_table_start + total * 4 local large_idx = band(pack_offset, 0x7fffffff) local large_pos = large_offset_table + large_idx * 8 local hi_word = read_u32_be(idx_data, large_pos) local lo_word = read_u32_be(idx_data, large_pos + 4) pack_offset = hi_word * 4294967296 + lo_word end return pack_offset elseif entry_sha < sha_bin then low = mid + 1 else high = mid - 1 end end return nil end local apply_delta = function(base_data, delta_data) local pos = 1 local len = #delta_data -- Read base size varint local base_size = 0 local shift = 0 while pos <= len do local b = string.byte(delta_data, pos) pos = pos + 1 base_size = base_size + band(b, 0x7f) * (2 ^ shift) shift = shift + 7 if band(b, 0x80) == 0 then break end end -- Read result size varint local result_size = 0 shift = 0 while pos <= len do local b = string.byte(delta_data, pos) pos = pos + 1 result_size = result_size + band(b, 0x7f) * (2 ^ shift) shift = shift + 7 if band(b, 0x80) == 0 then break end end local parts = {} while pos <= len do local cmd = string.byte(delta_data, pos) pos = pos + 1 if band(cmd, 0x80) ~= 0 then -- Copy from base local copy_offset = 0 local copy_size = 0 if band(cmd, 0x01) ~= 0 then copy_offset = string.byte(delta_data, pos) pos = pos + 1 end if band(cmd, 0x02) ~= 0 then copy_offset = copy_offset + string.byte(delta_data, pos) * 256 pos = pos + 1 end if band(cmd, 0x04) ~= 0 then copy_offset = copy_offset + string.byte(delta_data, pos) * 65536 pos = pos + 1 end if band(cmd, 0x08) ~= 0 then copy_offset = copy_offset + string.byte(delta_data, pos) * 16777216 pos = pos + 1 end if band(cmd, 0x10) ~= 0 then copy_size = string.byte(delta_data, pos) pos = pos + 1 end if band(cmd, 0x20) ~= 0 then copy_size = copy_size + string.byte(delta_data, pos) * 256 pos = pos + 1 end if band(cmd, 0x40) ~= 0 then copy_size = copy_size + string.byte(delta_data, pos) * 65536 pos = pos + 1 end if copy_size == 0 then copy_size = 0x10000 end parts[#parts + 1] = base_data:sub(copy_offset + 1, copy_offset + copy_size) elseif cmd > 0 then -- Insert literal parts[#parts + 1] = delta_data:sub(pos, pos + cmd - 1) pos = pos + cmd end end return table.concat(parts) end local read_pack_entry -- forward declaration local read_object -- forward declaration read_pack_entry = function(pack_path, offset, git_dir, depth) depth = depth or 0 if depth > 50 then return nil end -- Read enough data to parse the header and some content -- Start with a reasonable chunk local chunk_size = 4096 local f = io.open(pack_path, "rb") if not f then return nil end f:seek("set", offset) local chunk = f:read(chunk_size) if not chunk or #chunk == 0 then f:close() return nil end -- Parse type+size varint local pos = 1 local b = string.byte(chunk, pos) pos = pos + 1 local obj_type = band(rshift(b, 4), 0x07) local size = band(b, 0x0f) local shift = 4 while band(b, 0x80) ~= 0 do b = string.byte(chunk, pos) pos = pos + 1 size = size + lshift(band(b, 0x7f), shift) shift = shift + 7 end local type_names = { [1] = "commit", [2] = "tree", [3] = "blob", [4] = "tag" } if obj_type >= 1 and obj_type <= 4 then -- Regular object — zlib-compressed from pos onward local data_start = offset + pos - 1 f:seek("set", data_start) local compressed = f:read(math.max(size + 512, 8192)) f:close() if not compressed then return nil end local decompressed, bytes = zlib.decompress(compressed) if not decompressed then return nil end return { type = type_names[obj_type], size = size, content = decompressed } elseif obj_type == 6 then -- OFS_DELTA b = string.byte(chunk, pos) pos = pos + 1 local base_offset = band(b, 0x7f) while band(b, 0x80) ~= 0 do b = string.byte(chunk, pos) pos = pos + 1 base_offset = lshift(base_offset + 1, 7) + band(b, 0x7f) end local data_start = offset + pos - 1 f:seek("set", data_start) local compressed = f:read(math.max(size + 512, 8192)) f:close() if not compressed then return nil end local delta_data = zlib.decompress(compressed) if not delta_data then return nil end local base_obj = read_pack_entry(pack_path, offset - base_offset, git_dir, depth + 1) if not base_obj then return nil end local result = apply_delta(base_obj.content, delta_data) return { type = base_obj.type, size = #result, content = result } elseif obj_type == 7 then -- REF_DELTA local base_sha_bin = chunk:sub(pos, pos + 19) pos = pos + 20 local data_start = offset + pos - 1 f:seek("set", data_start) local compressed = f:read(math.max(size + 512, 8192)) f:close() if not compressed then return nil end local delta_data = zlib.decompress(compressed) if not delta_data then return nil end local base_sha_hex = crypto.bin_to_hex(base_sha_bin) local base_obj = read_object(git_dir, base_sha_hex, depth + 1) if not base_obj then return nil end local result = apply_delta(base_obj.content, delta_data) return { type = base_obj.type, size = #result, content = result } else f:close() return nil end end local get_cached_packs = function(git_dir) local pack_dir = git_dir .. "/objects/pack" local mtime = stat_mtime(pack_dir) local cached = pack_cache[git_dir] if cached and cached.mtime == mtime then return cached.packs end local entries = std.fs.fast_list_dir(pack_dir) local packs = {} if entries then for name, dtype in pairs(entries) do if dtype == 8 and name:match("%.idx$") then local idx_path = pack_dir .. "/" .. name local idx_data = read_binary_file(idx_path) if idx_data then packs[#packs + 1] = { idx_path = idx_path, idx_data = idx_data } end end end end pack_cache[git_dir] = { mtime = mtime, packs = packs } return packs end local read_pack_object = function(git_dir, sha_hex) local sha_bin = crypto.hex_to_bin(sha_hex) local packs = get_cached_packs(git_dir) for _, pack in ipairs(packs) do local pack_offset = find_in_pack_index(pack.idx_data, sha_bin) if pack_offset then local pack_path = pack.idx_path:gsub("%.idx$", ".pack") return read_pack_entry(pack_path, pack_offset, git_dir) end end return nil end ---! Read an object from the object database (loose or packed, deltas resolved) ---@ read_object(`git_dir`{.str}, `sha_hex`{.str}) -> `obj`{.tbl .or_nil} --[===[ Returns `{ type = "commit"|"tree"|"blob"|"tag", size = n, content = raw }` or nil when the object is not found. ]===] read_object = function(git_dir, sha_hex, depth) depth = depth or 0 if depth > 50 then return nil end local obj = read_loose_object(git_dir, sha_hex) if obj then return obj end return read_pack_object(git_dir, sha_hex) end --------------------------------------------------------------------------- -- Tree + commit parsing --------------------------------------------------------------------------- ---! Parse a raw commit object's content ---@ parse_commit(`content`{.str}) -> `commit`{.tbl} --[===[ Returns `{ tree = sha, parents = {sha, ...}, author = str, committer = str, message = str }`. ]===] local parse_commit = function(content) local result = { parents = {} } local header, message = content:match("^(.-)%\n%\n(.*)$") if not header then header = content message = "" end result.message = message for line in header:gmatch("[^\n]+") do local key, value = line:match("^(%S+)%s+(.+)$") if key == "tree" then result.tree = value elseif key == "parent" then table.insert(result.parents, value) elseif key == "author" then result.author = value elseif key == "committer" then result.committer = value end end return result end ---! Parse a raw tree object's content ---@ parse_tree(`content`{.str}) -> `entries`{.tbl} --[===[ Returns an array of `{ mode = str, name = str, sha = hex_str }` entries. ]===] local parse_tree = function(content) local entries = {} local pos = 1 local len = #content while pos <= len do local space = content:find(" ", pos, true) if not space then break end local mode = content:sub(pos, space - 1) local nul = content:find("\0", space + 1, true) if not nul then break end local name = content:sub(space + 1, nul - 1) local sha_bin = content:sub(nul + 1, nul + 20) if #sha_bin < 20 then break end local sha_hex = crypto.bin_to_hex(sha_bin) entries[#entries + 1] = { mode = mode, name = name, sha = sha_hex } pos = nul + 21 end return entries end local flatten_tree flatten_tree = function(git_dir, tree_sha, prefix, result) result = result or {} prefix = prefix or "" local obj = read_object(git_dir, tree_sha) if not obj or obj.type ~= "tree" then return result end local entries = parse_tree(obj.content) for _, entry in ipairs(entries) do local full_path = prefix == "" and entry.name or (prefix .. "/" .. entry.name) if entry.mode == "40000" then flatten_tree(git_dir, entry.sha, full_path, result) else result[full_path] = entry.sha end end return result end --------------------------------------------------------------------------- -- Ref listing --------------------------------------------------------------------------- local list_refs_walk list_refs_walk = function(git_dir, subdir, refs) local dir = git_dir .. "/" .. subdir local entries = std.fs.fast_list_dir(dir) if not entries then return refs end for name, dtype in pairs(entries) do if name ~= "." and name ~= ".." then local ref_path = subdir .. "/" .. name if dtype == 4 then list_refs_walk(git_dir, ref_path, refs) elseif dtype == 8 then local sha = resolve_ref(git_dir, ref_path) if sha then refs[ref_path] = sha -- A loose ref supersedes any packed peeled entry refs[ref_path .. "^{}"] = nil end end end end return refs end ---! List all refs of a repository: loose and packed, with peeled `^{}` entries for annotated tags ---@ list_refs(`git_dir`{.str}) -> `refs`{.tbl} local list_refs = function(git_dir) local refs = {} for ref, sha in pairs(parse_packed_refs(git_dir)) do refs[ref] = sha end list_refs_walk(git_dir, "refs", refs) local peeled = {} for ref, sha in pairs(refs) do if ref:match("^refs/tags/") and not ref:match("%^{}$") and not refs[ref .. "^{}"] then local obj = read_object(git_dir, sha) if obj and obj.type == "tag" then local target = obj.content:match("^object (%x+)") if target then peeled[ref .. "^{}"] = target end end end end for ref, sha in pairs(peeled) do refs[ref] = sha end return refs end --------------------------------------------------------------------------- -- Index parsing (.git/index v2/v3/v4) --------------------------------------------------------------------------- local parse_index = function(git_dir) local data = read_binary_file(git_dir .. "/index") if not data then return {} end if #data < 12 then return {} end local sig = data:sub(1, 4) if sig ~= "DIRC" then return {} end local version = read_u32_be(data, 5) local entry_count = read_u32_be(data, 9) local entries = {} local pos = 13 -- 1-indexed, after 12-byte header for _ = 1, entry_count do if pos + 62 > #data + 1 then break end local ctime_s = read_u32_be(data, pos) -- ctime_ns at pos+4 local mtime_s = read_u32_be(data, pos + 8) -- mtime_ns at pos+12 local dev = read_u32_be(data, pos + 16) local ino = read_u32_be(data, pos + 20) local mode_raw = read_u32_be(data, pos + 24) -- uid at pos+28, gid at pos+32 local size = read_u32_be(data, pos + 36) local sha_bin = data:sub(pos + 40, pos + 59) local flags = read_u16_be(data, pos + 60) local name_len = band(flags, 0xFFF) local stage = band(rshift(flags, 12), 0x03) local entry_start = pos pos = pos + 62 -- v3 extended flags if version >= 3 and band(flags, 0x4000) ~= 0 then pos = pos + 2 end local name if name_len < 0xFFF then name = data:sub(pos, pos + name_len - 1) pos = pos + name_len else local nul = data:find("\0", pos, true) if nul then name = data:sub(pos, nul - 1) pos = nul else break end end -- Skip to next 8-byte aligned boundary (relative to entry start in v2/v3) if version < 4 then local entry_len = pos - entry_start local padded = entry_len + (8 - (entry_len % 8)) % 8 if padded == entry_len then padded = padded + 8 end pos = entry_start + padded else -- v4: skip NUL byte after name if pos <= #data and string.byte(data, pos) == 0 then pos = pos + 1 end end entries[#entries + 1] = { name = name, sha = crypto.bin_to_hex(sha_bin), mtime_s = mtime_s, size = size, mode = mode_raw, stage = stage, dev = dev, ino = ino, } end return entries end return { find_git_dir = find_git_dir, read_object = read_object, resolve_ref = resolve_ref, read_head = read_head, parse_packed_refs = parse_packed_refs, parse_commit = parse_commit, parse_tree = parse_tree, flatten_tree = flatten_tree, list_refs = list_refs, parse_config = parse_config, get_tracking_branch = get_tracking_branch, parse_index = parse_index, }