--!pw caps=read:git -- @pw/diff: what `diff [path-prefix]` prints, the sidecars added, removed and -- updated between two revisions, grouped by the directory holding their `mods/` folder. -- -- A port of Packwand's Zig command (C# `ChangeSummary`), which Packwand's tests pinned byte for -- byte before this replaced it. The run's directory is the repository's root, so the paths git -- names are the paths the run reads. local util = require("@pw/util") local json = require("@pw/json") local diff = {} local EXTENSION = ".bun.json" export type Change = { slug: string, path: string, change: string, old_filename: string, new_filename: string, } export type Subdirectory = { subdirectory: string, added: number, removed: number, updated: number, mods: { Change }, } --- The file at `rev`, as `git show` hands it to the Zig command: the byte-order mark dropped, --- decoded leniently and trimmed; nil where the revision has no such file. local function show(rev: string, path: string): string? local bytes = pw.git.read(rev, path, { limit = 2 ^ 30 }) if bytes == nil then return nil end if util.starts_with(bytes, "\u{FEFF}") then bytes = string.sub(bytes, 4) end return util.trim(util.lossy(bytes)) end --- Everything before the last two components (`p/mods/a.bun.json` is in `p`), or "" for a path --- with fewer than three. local function subdirectory_of(path: string): string local parts = string.split(path, "/") if #parts < 3 then return "" end return table.concat(parts, "/", 1, #parts - 2) end --- The sidecar's `filename`, or "" when it does not parse: a broken file still counts as changed. local function filename_of(source: string): string local document = pw.sidecar.parse(source, "sidecar") if document == nil or type(document.filename) ~= "string" then return "" end return document.filename end --- The sidecar name without its extension, stripped once. local function slug_of(path: string): string local parts = string.split(path, "/") local name = parts[#parts] if util.ends_with(name, EXTENSION) then return string.sub(name, 1, #name - #EXTENSION) end return name end --- The changes between `from` and `to` under `prefix`, by subdirectory in code-point order. function diff.summary(from: string, to: string, prefix: string): { Subdirectory } local ok, changed = pcall(pw.git.changed, from, to) if not ok then -- git's own message, as the Zig command printed it. local message = tostring(changed) local host = "pw.git.changed: " pw.fail("operation_failed", if util.starts_with(message, host) then string.sub(message, #host + 1) else message) end local kept = {} for _, given in ipairs(changed) do local path = util.trim(given) if util.ends_with(path, EXTENSION) and util.starts_with(path, prefix) then table.insert(kept, path) end end table.sort(kept) -- What each sidecar held at either revision; false where it did not exist. local old, new = {}, {} for _, path in ipairs(kept) do old[path] = show(from, path) or false new[path] = show(to, path) or false end local groups: { [string]: { string } } = {} local names = {} for _, path in ipairs(kept) do local key = subdirectory_of(path) if groups[key] == nil then groups[key] = {} table.insert(names, key) end table.insert(groups[key], path) end table.sort(names) local summary = {} for _, key in ipairs(names) do local entry: Subdirectory = { subdirectory = key, added = 0, removed = 0, updated = 0, mods = {} } for _, path in ipairs(groups[key]) do local before = old[path] or nil local after = new[path] or nil local change = if before == nil and after ~= nil then "added" elseif before ~= nil and after == nil then "removed" else "updated" entry[change] += 1 table.insert(entry.mods, { slug = slug_of(path), path = path, change = change, old_filename = if before then filename_of(before) else "", new_filename = if after then filename_of(after) else "", }) end table.insert(summary, entry) end return summary end local function totals(summary: { Subdirectory }): (number, number, number) local added, removed, updated = 0, 0, 0 for _, entry in ipairs(summary) do added += entry.added removed += entry.removed updated += entry.updated end return added, removed, updated end --- `3 added, 1 updated`, leaving out the zeros, or `nothing to do`. local function tally(added: number, removed: number, updated: number): string local parts = {} for _, part in ipairs({ { added, "added" }, { removed, "removed" }, { updated, "updated" } }) do if part[1] > 0 then table.insert(parts, `{part[1]} {part[2]}`) end end return if #parts == 0 then "nothing to do" else table.concat(parts, ", ") end local function to_json(from: string, to: string, prefix: string, summary: { Subdirectory }): string local document = json.object() json.set(document, "old_ref", from) json.set(document, "new_ref", to) if prefix ~= "" then json.set(document, "path_prefix", prefix) end local subdirectories = json.array() for _, entry in ipairs(summary) do local mods = json.array() for _, mod in ipairs(entry.mods) do local row = json.object() json.set(row, "slug", mod.slug) json.set(row, "path", mod.path) json.set(row, "change", mod.change) if mod.old_filename ~= "" then json.set(row, "old_filename", mod.old_filename) end if mod.new_filename ~= "" then json.set(row, "new_filename", mod.new_filename) end json.push(mods, row) end local row = json.object() json.set(row, "subdir", entry.subdirectory) json.set(row, "added", entry.added) json.set(row, "removed", entry.removed) json.set(row, "updated", entry.updated) json.set(row, "mods", mods) json.push(subdirectories, row) end json.set(document, "subdirs", subdirectories) local added, removed, updated = totals(summary) json.set(document, "total_added", added) json.set(document, "total_removed", removed) json.set(document, "total_updated", updated) return json.pretty(document) end local function to_text(from: string, to: string, summary: { Subdirectory }): string if #summary == 0 then return `no {EXTENSION} changes between {from} and {to}\n` end local lines = {} for _, entry in ipairs(summary) do table.insert(lines, entry.subdirectory) for _, mod in ipairs(entry.mods) do if mod.change == "added" then table.insert(lines, ` + {mod.slug} {mod.new_filename}`) elseif mod.change == "removed" then table.insert(lines, ` - {mod.slug} {mod.old_filename}`) elseif mod.old_filename ~= mod.new_filename then table.insert(lines, ` ~ {mod.slug} {mod.old_filename} -> {mod.new_filename}`) else table.insert(lines, ` = {mod.slug}`) end end table.insert(lines, " " .. tally(entry.added, entry.removed, entry.updated)) end table.insert(lines, "") table.insert(lines, `{from}..{to}: {tally(totals(summary))}`) return table.concat(lines, "\n") .. "\n" end --- `options_json`: `{"from","to","prefix"?,"json"?}`. Reports what the command prints as one text. function diff.main(options_json: string) local options = pw.json.decode(options_json) local from: string, to: string = options.from, options.to local prefix: string = options.prefix or "" for _, ref in ipairs({ from, to }) do if util.starts_with(ref, "-") then pw.fail("invalid_request", `"{ref}" looks like an option, not a git ref`) end end local summary = diff.summary(from, to, prefix) pw.report.text(if options.json then to_json(from, to, prefix, summary) else to_text(from, to, summary)) end return diff