glibc/SOURCES/patch-git.lua

2037 lines
64 KiB
Lua

-- patch-git, a patch management tooling for dist-git.
-- Copyright Red Hat, Inc.
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU General Public License for more details.
--
-- You should have received a copy of the GNU General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
--[==[
The canonical source for this file is here:
* https://gitlab.com/redhat/centos-stream/rpms/glibc/-/blob/c10s/patch-git.lua
To activate patch-git, add this file into your dist-git repository, and
add this line to the spec file:
%{lua:dofile(rpm.expand([[%_sourcedir/patch-git.lua]]))}
Do not indent this line, and use this line verbatim. Some tools
may use it to recognize that patch-git is in use.
If patch-git can infer the patch list correctly, you can remove all
Patch…: lines from the spec file, and replace them with a line
like this:
%{lua:patchgit.patches()}
If the patch-git heuristics do not result in a correct patch
application order (which can happen if sorting patches
lexicographically within one commit does not yield the correct patch
application order), you can keep historic Patch…: lines before the
patchgit.patches() line.
To auto-generate the changelog, start the %changelog section like this:
%changelog
%{lua:patchgit.changelog()}
This will add auto-generated changelog entries from the Git history.
]==]
-- The patchgit global variable caches data extracted from Git-generated files,
-- and provides functions that can be called from the spec file.
--
-- patchgit.commits: a list which contains the commit history. The
-- oldest commit is at index 1, its successor is at index 2,
-- and so on. The table entries are themselves tables with the
-- following fields:
-- commit: Git commit hash (40 hexadecimal digits)
-- author: name and email address of the commit author
-- author_date: author date according to Git
-- committer: like author, but for the Git committer
-- commit_date: like author_date, but for the commit
-- message: the commit message (unparsed)
-- changes: the file changes (from git log --raw output). A list
-- of tables with the following fields:
-- src_mode: number with file mode, 0 for creation
-- dst_mode: number with file mode, 0 for deletion
-- src_blob: abbreviated blob hash for original
-- dst_blob: abbreviated blob hash for result
-- status: flags, should only be 'A' (add), 'M' (modify),
-- 'D' (delete) (due to --no-renames)
-- path: path of the blob being changed
--
-- patchgit.patches(): Emit a patch list constructed from Git history
-- into the spec file.
--
-- patchgit.changelog(): Emit auto-generated changelog entries into the
-- spec file.
--
patchgit = {}
-- Used to trigger file regeneration in case of changes. This is only
-- necessary to change if the set of files or the data format in the
-- files is changed. If data extraction from the files is modified,
-- this change will take place immediately even if those files are not
-- regenerated, so no VERSION update is needed.
local VERSION = 1
-- Cached value of %_sourcedir from the RPM environment. When not
-- running under rpm, default to the current directory.
local sourcedir
if rpm then
sourcedir = rpm.expand('%_sourcedir')
else
-- If not running under rpm, default to the current directory.
sourcedir = '.'
end
-- This file contains the commit hash for HEAD (corresponding to the
-- rest of the files) and the VERSION marker.
local git_commit_file = 'patch-git-generated-commit.txt'
-- This file contains git log --raw output.
local git_log_file = 'patch-git-generated-log.txt'
-- Read the file with the specified name in the RPM source directory.
-- Returns nil and an error message if the source file cannot be opened.
local function read_source_file(name)
local path = sourcedir .. '/' .. name
local fp, err = io.open(path, 'r')
if not fp then
return fp, err
end
local s = fp:read('a')
assert(fp:close())
return s
end
-- Write the specified contents to a file with the specified name in
-- the RPM source directory. Asserts if the file cannot be written.
local function write_source_file(name, contents)
local fp = assert(io.open(sourcedir .. '/' .. name, 'w+'))
assert(fp:write(contents))
assert(fp:close())
end
-- Run 'git ' .. cmd in the RPM source directory and return the output
-- string. Assert that the command completed successfully.
local function run_git(cmd)
cmd = 'cd ' .. sourcedir .. ' && TZ=UTC LC_ALL=C.utf8 git ' .. cmd
local fp = assert(io.popen(cmd, 'r'))
local s = fp:read('a')
local ok, term, status = fp:close()
if not ok or term ~= 'exit' or status ~= 0 then
assert(false, cmd .. ': ' .. term .. ' ' .. status)
end
return s
end
-- Check the result of os.execute and the close method on streams
-- created by io.popen. The first argument is the command to use in
-- an error message.
local function check_cmd_result(cmd, ok, term, status)
if not ok or term ~= 'exit' or status ~= 0 then
assert(false, cmd .. ': ' .. term .. ' ' .. status)
end
end
-- Run the command with the shell and return the output.
local function run_shell(cmd)
local fp = assert(io.popen(cmd, 'r'))
local s = fp:read('a')
check_cmd_result(cmd, fp:close())
return s
end
-- Calls f with a temporary file name as an argument, which is created
-- automatically. Delete the file once f returns (normally or through
-- an error).
local function with_temporary_file(f, ...)
local tmpfile = assert(string.match(run_shell('mktemp'), '^(/.*)\n$'))
local fp = assert(io.open(tmpfile, 'w+'))
return (function(ok, ...)
os.remove(tmpfile)
if ok then
return ...
else
error(...)
end
end)(pcall(f, tmpfile, ...))
end
-- Run 'git ' .. cmd in the RPM source directory and assert that the
-- command completed successfully.
local function check_git(cmd)
cmd = 'cd ' .. sourcedir .. ' && TZ=UTC LC_ALL=C.utf8 git ' .. cmd
check_cmd_result(cmd, os.execute(cmd))
end
-- Lists the contents of the directory. The special entries '.' and
-- '..' are included.
local function list_directory(path)
if posix then
return posix.dir(path)
else
-- Not running under rpm. Avoid additional dependencies
-- by falling back to ls. Not ideal, but gets the job done
-- (unless there are files with newlines in their names).
local result = {}
local cmd = 'ls -a -- ' .. path
local fp = assert(io.popen(cmd, 'r'))
while true do
local line = fp:read('l')
if not line then
break
end
result[#result + 1] = line
end
check_cmd_result(cmd, fp:close())
return result
end
end
-- Return the single RPM spec file name from the source directory.
local function get_single_spec_file()
local spec
for _, file in ipairs(list_directory(sourcedir)) do
if string.match(file, '%.spec$') then
if spec then
error('multiple RPM spec files: '
.. spec .. ' and ' .. file)
end
spec = file
break
end
end
if not spec then
error('RPM spec file not found')
end
return spec
end
-- Quote a string so that shell meta-characters are not interpreted by
-- the shell anymore.
local function shell_quote(s)
if s == '' then
return "''"
end
if not string.match(s, '[^A-Za-z0-9_./+-]') then
-- The string does not contain shell meta-characters.
return s
end
-- Replace "'" with a sequence that ends the string, emits an escaped
-- "'", and then starts a new string.
return "'" .. string.gsub(s, "'", [['\'']]) .. "'"
end
-- Tests for shell_quote.
do
assert(shell_quote('') == "''")
assert(shell_quote('foo') == 'foo')
assert(shell_quote('foo bar') == "'foo bar'")
assert(shell_quote("foo'bar") == "'foo'\\''bar'")
assert(shell_quote("'foo'bar") == "''\\''foo'\\''bar'")
end
local function shell_args(args)
local result = {}
for _, arg in ipairs(args) do
result[#result + 1] = shell_quote(arg)
end
return table.concat(result, ' ')
end
-- Tests for shell_args.
do
assert(shell_args({}) == '')
assert(shell_args({'foo', 'bar'}) == "foo bar")
assert(shell_args({'foo', "bar'baz"}) == [[foo 'bar'\''baz']])
end
-- Evaluate the string using rpmspec against the spec file
-- (previously obtained with get_single_spec_file). Return the result
-- of the evaluation. The mode of operation needs to be selected with
-- rpmspec options such as --eval, --qf, -P.
local run_rpmspec
-- Same as check_rpmspec, but print the result to stdout.
local check_rpmspec
do
local function cmd(spec, ...)
return shell_args({'rpmspec', '-D', '_sourcedir ' .. sourcedir,
sourcedir .. '/' .. spec, ...})
end
function run_rpmspec(spec, ...)
local script = cmd(spec, ...)
local fp = assert(io.popen(script, 'r'))
local result = assert(fp:read('a'))
check_cmd_result(script, fp:close())
return result
end
function check_rpmspec(spec, ...)
local script = cmd(spec, ...)
check_cmd_result(script, os.execute(script))
end
end
-- Testing helper: assert_eq(a, b) asserts if not a == b.
-- A third argument can be provided with a context string.
local assert_eq
do
-- quote(v) returns a string that evaluates to v, mostly in Lua syntax.
local quote
do
local quote_table = {
['\n'] = '\\n',
['\r'] = '\\r',
['\t'] = '\\t',
['\0'] = '\000',
['"'] = '\\"',
['\\'] = '\\\\',
}
local function quote_table_update(i)
local ch = string.char(i)
if quote_table[ch] == nil then
quote_table[ch] = string.format('\\x%02x', i)
end
end
for i=0,31 do
quote_table_update(i)
end
for i=127,255 do
quote_table_update(i)
end
local function quote1(v, seen)
if v == nil then
return 'nil'
elseif v == true then
return 'true'
elseif v == false then
return 'false'
elseif type(v) == 'number' then
return string.format('%q', v)
elseif type(v) == 'string' then
return '"' .. string.gsub(v, '.', quote_table) .. '"'
elseif type(v) == 'table' then
-- Prevent infinite recursion.
local idx = seen[v]
if idx then
return '&' .. idx
end
local seen_count = seen[1] + 1
seen[1] = seen_count
seen[v] = seen_count
local count = 0
for _, _ in pairs(v) do
count = count + 1
end
local result = {}
if count == #v then
-- Regular table.
for i=1,count do
result[i] = quote1(v[i], seen)
end
else
-- Not a regular table.
for key, value in pairs(v) do
result[#result + 1] =
'[' .. quote1(key, seen) .. ']='
.. quote1(value, seen)
end
end
return '{' .. table.concat(result, ', ') .. '}'
else
return '#<' .. type(v) .. ':' .. quote(tostring(v)) .. '>'
end
end
function quote(v)
return quote1(v, {0})
end
end
assert(quote('') == '""')
assert(quote('\n') == '"\\n"')
assert(quote('{}') == '"{}"')
assert(quote({}) == '{}')
assert(quote({1, 2, 3}) == '{1, 2, 3}')
assert(quote({a=1}) == '{["a"]=1}')
local deep_eq
do
local function deep_eq1(a, b, seen)
if a == b then
return true
elseif type(a) == 'table' and type(b) == 'table' then
assert(not seen[a])
assert(not seen[b])
seen[a] = true
seen[b] = true
local acount = 0
for ak, av in pairs(a) do
if not deep_eq1(av, b[ak], seen) then
return false
end
acount = acount + 1
end
local bcount = 0
for bk, bv in pairs(b) do
bcount = bcount + 1
end
return acount == bcount
else
return false
end
end
function deep_eq(a, b)
if a == b then
return true
elseif type(a) == 'table' and type(b) == 'table' then
return deep_eq1(a, b, {})
end
end
end
function assert_eq(a, b, ctx)
if deep_eq(a, b) then
return
end
local prefix
if ctx then
prefix = ctx .. ': '
else
prefix = ''
end
assert(a == b, prefix .. quote(a) .. ' ~= ' .. quote(b))
end
end
-- Sort the list lexicographically, in place, treating sequences of
-- digits as a single positive decimal number.
local function version_sort(list)
-- Sorting is only needed for two or more elements.
if #list <= 1 then
return
end
-- Maximum length of a sequence of consecutive digits in patch names.
local max_width = 1
for i=1,#list do
local s = list[i]
for number in string.gmatch(s, '%d+') do
if #number > max_width then
max_width = #number
end
end
end
-- Pad the number argument with leading '0' to max_width.
local function pad(s)
return string.rep('0', max_width - #s) .. s
end
local padded = {}
for i=1,#list do
local s = list[i]
padded[s] = string.gsub(s, '%d+', pad)
end
table.sort(list, function (a, b)
return padded[a] < padded[b]
end)
end
-- Tests for version_sort.
do
local test = {'b2-30b', 'b1', 'b10', 'b2', 'a', 'b2-30', 'b2-4'}
version_sort(test)
assert(#test == 7)
assert(test[1] == 'a')
assert(test[2] == 'b1')
assert(test[3] == 'b2')
assert(test[4] == 'b2-4')
assert(test[5] == 'b2-30')
assert(test[6] == 'b2-30b')
assert(test[7] == 'b10')
end
-- Returns true if the git command version is at least that of the
-- argument string.
local check_git_version
do
-- Cached version of the git command.
local git_version
function check_git_version(reference)
if not git_version then
local output = run_git('version')
git_version = assert(
string.match(output, '^git version (%d[^\n]*)\n'), output)
end
-- If the reference version sorts first, the version check succeeds.
local sorted = {git_version, reference}
version_sort(sorted)
return sorted[1] == reference
end
end
-- Called to generate the files (if HEAD or the script version has changed).
local function generate_files()
-- True if %_sourcedir refers to a Git repository and git is installed.
local have_git = (posix == nil -- Not running under rpm.
or (posix.access('/usr/bin/git', 'x')
and posix.access(sourcedir .. '/.git/.', 'x')))
local commit_marker_contents -- For git_commit_file.
if have_git then
commit_marker_contents =
run_git('rev-parse HEAD') .. 'v' .. VERSION .. '\n'
if commit_marker_contents == read_source_file(git_commit_file) then
-- HEAD and version did not change. No file generation
return
end
elseif not read_source_file(git_commit_file) then
assert(false, 'no Git repository and no captured Git history')
else
-- No Git, so no regeneration possible, but the required files
-- are present. No work to do.
return
end
-- At this point, we have a Git repository, and we need to regenerate the
-- patch-git-generated*.txt files. Make sure that the repository is not
-- shallow.
do
local shallow = run_git('rev-parse --is-shallow-repository')
if shallow == 'true\n' then
if check_git_version('2.28') then
check_git('fetch --unshallow --filter=blob:none')
else
-- 2.27 and earlier error out if the original clone did not
-- use --filter.
check_git('fetch --unshallow')
end
else
assert(shallow == 'false\n', shallow)
end
end
-- Delete the marker file if it exists, to force regeneration after
-- partial generation below.
os.remove(sourcedir .. '/' .. git_commit_file)
-- Produce the Git log. The --first-parent option ignores side
-- branches (so that it is possible to merge in arbitrary history
-- without growing the log too much, and to control the
-- script-processed commit messages). With --no-renames, no blobs
-- are needed (see --filter=blob:none above). With --raw,
-- information about the changed files is preserved (required for
-- patch depth sorting below). To include committer dates, use
-- --pretty=fuller.
check_git(
'log --first-parent --no-renames --raw --pretty=fuller --date=default > '
.. git_log_file)
-- Atomically replace the contents of git_commit_file, confirming that
-- the new Git log has been written.
write_source_file(git_commit_file .. '.tmp', commit_marker_contents)
assert(os.rename(sourcedir .. '/' .. git_commit_file .. '.tmp',
sourcedir .. '/' .. git_commit_file))
end
-- Older RPM versions use doubles for patch and source numbers, not
-- integers. This causes problems because current Lua formats such
-- numbers with a decimal point. Therefore, use tointeger when
-- obtaining numbers from source_nums, patch_nums. If the Lua
-- interpreter has the double/integer distinction, it defines
-- math.tointeger. Otherwise, the conversion is not necessary, and
-- doubles that are formatted without a decimal point if there are
-- integers.
local tointeger = math.tointeger
if not tointeger then
function tointeger(n)
return n
end
end
-- Inject Sources: lines for the auto-generated files and this script
-- into the spec file.
local function emit_sources()
-- If not running under rpm, the source list is not meaningful. Do
-- not print it.
if not rpm then
return
end
local max = 0
for i=1,#source_nums do
local k = source_nums[i]
if k > max then
max = tointeger(k)
end
end
local function emit(name)
max = max + 1
print('Source' .. max .. ': ' .. name .. '\n')
end
emit(git_commit_file)
emit(git_log_file)
emit('patch-git.lua') -- This file.
end
local function parse_commits()
local fp = assert(io.open(sourcedir .. '/' .. git_log_file), 'r')
local line -- The current line. Updated by readline1(), readline().
local lineno = 0 -- Its number.
local function readline1() -- No error checking, may return nil.
lineno = lineno + 1
line = fp:read('L') -- Include '\n'.
return line
end
local function readline() -- Does not return nil.
if not readline1() then
assert(false, 'unexpected end of file at line ' .. lineno)
end
return line
end
local function check(cond) -- Report an error if not cond.
if not cond then
io.stderr:write(git_log_file .. ':' .. lineno .. ': error: '
.. line)
io.stderr:write(debug.traceback(nil, 2))
error('git log parse error')
end
end
local commits = {}
readline()
while line do
local commit = string.match(line, '^commit ([0-9-a-f]+)\n')
check(commit and #commit == 40)
if string.match(readline(), '^Merge: ') then
readline()
end
local author = string.match(line, '^Author: (.+)\n')
check(author)
local author_date = string.match(readline(), '^AuthorDate: (.+)\n')
check(author_date)
local committer = string.match(readline(), '^Commit: (.+)\n')
check(committer)
local commit_date = string.match(readline(), '^CommitDate: (.+)\n')
check(commit_date)
check(readline() == '\n') -- Separator between header and commit message.
-- Read the commit message. Remove the indentation.
local remove_indent = '^ (.*\n)'
local message = {string.match(readline(), remove_indent)}
check(message[1])
while true do
if not readline1() then
-- EOF in initial commit message.
break
end
local l = string.match(line, remove_indent)
if not l then break
-- No longer the commit message.
break
end
message[#message + 1] = l
end
message = table.concat(message)
if line == '\n' then
readline1()
end
-- Read the file changes (lines starting with ':').
local changes = {}
while line and string.match(line, '^:') do
local src_mode, dst_mode, src_blob, dst_blob, status, path =
string.match(
line,
'^:([0-7]+) ([0-7]+) ([0-9a-f]+) ([0-9a-f]+) ([^\t]+)\t([^\n]+)\n')
check(string.match(status, '^[ADM]$')) -- See patchgit.patches below.
check(path)
changes[#changes + 1] = {
src_mode=tonumber(src_mode, 8),
dst_mode=tonumber(dst_mode, 8),
src_blob=src_blob,
dst_blob=dst_blob,
status=status,
path=path,
};
readline1()
end
-- Store the commit.
commits[#commits + 1] = {
commit=commit,
author=author,
author_date=author_date,
committer=committer,
commit_date=commit_date,
message=message,
changes=changes,
}
if line == '\n' then
readline1()
end
end
assert(fp:close())
-- Reverse the order of the commits list, so that the oldest commit
-- comes first.
do
local i = 1
local j = #commits
while i < j do
local tmp = commits[i]
commits[i] = commits[j]
commits[j] = tmp
i = i + 1
j = j - 1
end
end
patchgit.commits = commits
end
-- Inject the Patch: lines into the spec file, in the appropriate
-- (commit) order. Unapplied patches found in the source directory
-- and that are not present in the Git history are applied last.
function patchgit.patches(options)
local history_only = options and options.history_only
emit_sources()
-- Maximum patch number emitted so far.
local patchno = 0
-- True entries for patch files that have already been applied in
-- the spec file. Only populated when running under rpm.
local preordered = {}
-- Table with the patches in the source directory. As patches are
-- scheduled for application, they are removed from this table.
local patches_in_sourcedir = {}
if not history_only then
for i=1,#patches do
-- Remove the %_sourcedir prefix.
local pname = assert(string.match(patches[i], '.*/([^/]+)$'))
preordered[pname] = true
local n = patch_nums[i]
if n > patchno then
patchno = tointeger(n)
end
end
for _, fname in ipairs(list_directory(sourcedir)) do
if string.find(fname, '%.patch$') and not preordered[fname] then
patches_in_sourcedir[fname] = true
end
end
end
-- Table indexed by patch name, mapping it to the age (number) of
-- the patch. Lower age numbers are applied earlier.
local patch_age = {}
for age, commit in ipairs(patchgit.commits) do
for _, change in ipairs(commit.changes) do
-- Only look at patch files in the top-level directory.
if string.match(change.path, '^[^/]+%.patch$') then
if change.status == 'A' then -- Add.
assert(not patch_age[change.path], change.path)
patch_age[change.path] = age
elseif change.status == 'D' then -- Delete.
assert(patch_age[change.path], change.path)
patch_age[change.path] = nil
elseif change.status == 'M' then -- Modify.
assert(patch_age[change.path], change.path)
else
assert(false) -- See [ADM] match above.
end
end
end
end
-- If not running under rpm, the default Lua print function behaves
-- differently (it adds a '\n'). Directly write to stdout to avoid
-- adding the extra '\n'.
local emit
if rpm then
emit = print
else
function emit(s)
io.stdout:write(s)
end
end
-- Group the patches by age. The keys of by_age are the age numbers.
-- The values are lists of patch names (initially unsorted).
local by_age = {}
local max_age = #patchgit.commits
for patch, age in pairs(patch_age) do
assert(age <= max_age)
local patchlist = by_age[age]
if not patchlist then
patchlist = {}
by_age[age] = patchlist
end
patchlist[#patchlist + 1] = patch
end
-- Perform version sort on the table and emit the patch names in
-- that order.
local function emit_patchlist(patchlist)
-- Within one commit, patches are sorted lexicographically.
-- Remove the '.patch' suffix, so that it does not interfere
-- with sorting ('patch2b.patch' sorting before 'patch2.patch').
local list = {}
for i, name in ipairs(patchlist) do
list[i] = assert(string.match(name, '^(.+)%.patch$'))
end
version_sort(list)
for i=1,#list do
local pname = list[i] .. '.patch'
if not preordered[pname] then
patchno = patchno + 1
emit('Patch' .. patchno .. ': ' .. pname .. '\n')
patches_in_sourcedir[pname] = nil
end
end
end
-- Do not use ipairs here because the by_age table may have holes.
-- Iterate over the maximum possible age range instead.
for age=1,max_age do
local patchlist = by_age[age]
if patchlist then
emit_patchlist(patchlist)
end
end
-- Emit the unapplied patches in the source directory.
if not history_only then
local remaining_patches = {}
for patch, _ in pairs(patches_in_sourcedir) do
remaining_patches[#remaining_patches + 1] = patch
end
emit_patchlist(remaining_patches)
end
end
----------------------------------------------------------------------
-- Processing of commit messages, for release numbers and changelogs
----------------------------------------------------------------------
-- Return a table with the issue numbers in the string.
-- Return nil if the argument is not valid or empty.
-- If previous is not nil, the tickets are appended to this list.
local function parse_ticket_string(tag, s, previous)
local result
if previous ~= nil then
result = previous
else
result = {}
end
local old = #result
for ref1 in string.gmatch(s, '[ \t\n]*([^ \t\n]+)[ \t\n]*') do
local ref = string.match(ref1, '([a-zA-Z0-9#-]+),?')
if not ref then
return nil, 'invalid ticket reference: ' .. ref1
end
if not string.match(ref, '.*%d$') then
return nil, 'ticket reference without trailing number: ' .. ref
end
for i=1,#result do
if result[i] == ref1 then
return nil, 'duplicate ticket reference: ' .. ref
end
end
result[#result + 1] = ref
end
if #result == old then
return nil, 'no ticket references found in ' .. tag
end
return result
end
-- Tests for parse_ticket_string.
do
local t, err
t = assert(parse_ticket_string('Resolves', ' RHEL-1234 swbz#567\n'))
assert(#t == 2)
assert(t[1] == 'RHEL-1234')
assert(t[2] == 'swbz#567')
t = assert(parse_ticket_string('Resolves', ' RHEL-1234, swbz#567\n'))
assert(#t == 2)
assert(t[1] == 'RHEL-1234')
assert(t[2] == 'swbz#567')
t, err = parse_ticket_string('Related', ' ')
assert(not t)
assert(err == 'no ticket references found in Related')
t, err = parse_ticket_string('Related', ' bug 567')
assert(not t)
assert(err == 'ticket reference without trailing number: bug')
end
-- Append a line to the changelog table. It is prefixed with '- '.
-- Long lines are wrapped at word boundaries and indented.
local function append_to_rpm_changelog(line, result)
if #result == 0 then
result[1] = '-'
end
for word in string.gmatch(line, '%S+') do
if #result[#result] + #word > 76 then
result[#result + 1] = ' ' .. word
else
result[#result] = result[#result] .. ' ' .. word
end
end
end
-- Extract the trailers from the passed commit message. Returns a
-- single table where keys are derived from the trailer tags.
-- On error, returns nil and an error message.
--
-- The result table may contain the following fields:
--
-- tickets: list of ticket reference strings (Resolves:, Related:)
-- parent: Git commit hash, 40 characters (Parent:)
-- patchgit_version: numeric revision number for patch-git directives.
-- rpm_branch_type: 'zstream' if present (RPM-Branch-Type:)
-- rpm_skip_release: boolean; true to skip release increment (RPM-Skip-Release:)
-- rpm_changelog: table of changelog entry strings
-- rpm_changelog_stop: boolean; true to stop including older commits
-- (RPM-Changelog-Stop:)
-- rpm_release: RPM release string (RPM-Release:)
-- rpm_version: RPM version string (RPM-Version:)
--
-- The rpm_changelog table contains zero or more strings, each of
-- which is a changelog entry. The leading '-' is not included.
local parse_trailer
do
-- Parser for Patch-Git-Version.
local function parse_patchgit_version(tag, s)
s = string.match(s, '%s*(%d+)%s*$')
local n = s and tonumber(s)
if not s or s ~= tostring(n) then
return nil, tag .. ' does not contain a number'
end
return n
end
-- Tests for parse_patchgit_version.
do
local n, err
assert(parse_patchgit_version('Patch-Git-Version', ' 0\n') == 0)
assert(parse_patchgit_version('Patch-Git-Version', ' 1\n') == 1)
assert(parse_patchgit_version('Patch-Git-Version', ' 2\n') == 2)
n, err = parse_patchgit_version('Patch-Git-Version', '')
assert(not n and err == 'Patch-Git-Version does not contain a number')
n, err = parse_patchgit_version('Patch-Git-Version', ' \n')
assert(not n and err == 'Patch-Git-Version does not contain a number')
n, err = parse_patchgit_version('Patch-Git-Version',
' 9999999999999999999\n')
assert(not n and err == 'Patch-Git-Version does not contain a number')
end
-- Validator for RPM-Changelog. It returns the changelog entries
-- as a table of lines. The lines start with '- ' or ' ', unless
-- they are empty.
local function parse_rpm_changelog(tag, s)
assert(string.sub(s, #s) == '\n')
-- '-' is used to denote no changelog entry.
if string.match(s, '%s*%-%s*$') then
return {}
end
local lines = {}
for line in string.gmatch(s, '([^\n]+)\n') do
lines[#lines + 1] = line
end
-- Remove leading whitespace from the first line.
lines[1] = assert(string.match(lines[1], '^[ \t]*(.*)$'))
if #lines == 1 then
-- Nothing to do
else
-- Strip shared whitespace prefix from second and further lines.
-- First compute the shared prefix.
local prefix = assert(string.match(lines[2], '^([ \t]+)'))
for i=3,#lines do
-- Reduce the shared prefix length until equality is reached.
while prefix ~= '' and string.sub(lines[i], 1, #prefix) ~= prefix do
prefix = string.sub(prefix, 1, #prefix - 1)
end
end
local skip = #prefix + 1
-- Then strip the shared prefix.
for i=2,#lines do
lines[i] = string.sub(lines[i], skip)
end
end
local result = {}
if not string.match(lines[1], '^- ') then
-- This is not an itemized changelog entry. Concatenate all
-- lines with word-wrapping.
for _, line in ipairs(lines) do
append_to_rpm_changelog(line, result)
end
else
local dashed = false
for lineno, line in ipairs(lines) do
if lineno > 1 and string.match(line, '^- ') then
dashed = true
end
end
for lineno, line in ipairs(lines) do
if not string.match(line, '^- ') then
if string.match(line, '^%s*$') then
line = ''
elseif not dashed then
-- If there are no '- ' lines in the continuation
-- part, all lines need to be indented to line up
-- with with the '- ' from the first line.
line = ' ' .. line
end
end
result[lineno] = line
end
end
return result
end
-- Tests for parse_rpm_changelog.
do
local function prc(s)
return parse_rpm_changelog('RPM-Changelog', s)
end
-- Single-line changelog entry, not itemized.
assert_eq(assert(prc('Switch to patch-git\n')),
{'- Switch to patch-git'})
-- Single-line changelog entry, itemized.
assert_eq(assert(prc(' - Switch to patch-git\n')),
{'- Switch to patch-git'})
-- Multi-line changelog entry, not itemized.
assert_eq(assert(prc(' Switch to\n patch-git\n')),
{'- Switch to patch-git'})
-- Multi-line changelog entry, one item.
assert_eq(assert(prc('- Switch to\n patch-git\n')),
{'- Switch to', ' patch-git'})
-- Multi-line changelog entry, two items.
assert_eq(assert(prc([[- Switch to
patch-git
- Additional patch-git
fixes
]])),
{'- Switch to',
' patch-git',
'- Additional patch-git',
' fixes'})
end
local function parse_rpm_release(tag, s)
s = string.match(s, '^%s(%g+)%s*$')
if not s then
return nil, tag .. ' must contain a single word'
end
local dist = string.find(s, '%{?dist}', 1, true)
if not dist then
return nil, tag .. ' must contain %{?dist}'
end
if string.find(s, '%%.*%%{%?dist}')
or string.find(s, '%%{%?dist}.*%%') then
return nil, tag .. ' contains unexpected RPM macros'
end
if string.find(s, '-', 1, true) then
return nil, tag .. ' contains "-"'
end
if not string.find(s, '%d') then
return nil, tag .. ' must contain a digit'
end
return s
end
-- Tests for parse_rpm_release.
do
local r, err
assert(parse_rpm_release('RPM-Release', ' 59%{?dist}\n') == '59%{?dist}')
r, err = parse_rpm_release('RPM-Release', ' 59.el10\n')
assert(not r)
assert(err == 'RPM-Release must contain %{?dist}')
r, err = parse_rpm_release('RPM-Release', ' 59.el10_0\n')
assert(not r)
assert(err == 'RPM-Release must contain %{?dist}')
r, err = parse_rpm_release('RPM-Release', ' %{?dist}\n')
assert(not r)
assert(err == 'RPM-Release must contain a digit')
r, err = parse_rpm_release('RPM-Release', '59 %{?dist}')
assert(not r)
assert(err == 'RPM-Release must contain a single word')
end
local function parse_parent(tag, s)
local commit = string.match(s, '^%s*([0-9a-f]+)%s*\n')
if not commit then
return nil, 'commit hash expected in ' .. tag
elseif #commit ~= 40 then
return nil, 'full 40-character commit hash needed in ' .. tag
end
return commit
end
do
local r, err
assert(assert(parse_parent('Parent',
' 92dfd986b2f2c697144be2ebe10a27d72c660ba4\n'))
== '92dfd986b2f2c697144be2ebe10a27d72c660ba4')
r, err = parse_parent('Parent',
' 92dfd986b2f2c697144be2ebe10a27d72c660ba4.\n')
assert(not r)
assert(err == 'commit hash expected in Parent')
r, err = parse_parent('Parent',
' 92dfd986b2f2c697144be2ebe10a27d72c660ba\n')
assert(not r)
assert(err == 'full 40-character commit hash needed in Parent')
end
local function parse_rpm_version(tag, s)
s = string.match(s, '^%s(%g+)%s*$')
if not s then
return nil, tag .. ' must contain a single word'
end
if string.find(s, '%', 1, true) then
return nil, tag .. ' contains unexpected RPM macros'
end
if string.find(s, '-', 1, true) then
return nil, tag .. ' contains "-"'
end
if not string.find(s, '%d') then
return nil, tag .. ' must contain a digit'
end
return s
end
-- Tests for parse_rpm_version.
do
local v, err
assert(parse_rpm_version('RPM-Version', ' 2.39.1\n') == '2.39.1')
v, err = parse_rpm_version('RPM-Version', ' 1%{?dist}\n')
assert(not v)
assert(err == 'RPM-Version contains unexpected RPM macros')
v, err = parse_rpm_version('RPM-Version', ' %{version}\n')
assert(not v)
assert(err == 'RPM-Version contains unexpected RPM macros')
v, err = parse_rpm_version('RPM-Version', ' 2-39\n')
assert(not v)
assert(err == 'RPM-Version contains "-"')
v, err = parse_rpm_version('RPM-Version', ' version\n')
assert(not v)
assert(err == 'RPM-Version must contain a digit')
v, err = parse_rpm_version('RPM-Version', ' 2 39\n')
assert(not v)
assert(err == 'RPM-Version must contain a single word')
end
local string_to_bool = {
yes=true,
no=false,
['true']=true,
['false']=false,
['0']=false,
['1']=true,
}
local function parse_bool(tag, s)
s = string.match(s, '^%s*([^%s]+)%s*$')
local flag
if s ~= nil then
flag = string_to_bool[s]
end
if flag == nil then
return nil, tag .. ' must be yes/no'
end
return flag
end
-- Tests for parse_bool.
do
local bad = {'', 'y', 'n', '0 0', '0 1', 'none', 'Yes', 'No', 't', 'f'}
local function ps(pfx, sfx)
assert(parse_bool('Bool', pfx .. 'yes' .. sfx) == true)
assert(parse_bool('Bool', pfx .. 'no' .. sfx) == false)
assert(parse_bool('Bool', pfx .. 'true' .. sfx) == true)
assert(parse_bool('Bool', pfx .. 'false' .. sfx) == false)
assert(parse_bool('Bool', pfx .. '0' .. sfx) == false)
assert(parse_bool('Bool', pfx .. '1' .. sfx) == true)
for _, value in ipairs(bad) do
local r, err = parse_bool('Bool', pfx .. value .. sfx)
assert(not r, value)
assert(err == 'Bool must be yes/no')
end
end
ps('', '')
ps(' ', '\n')
ps(' ', ' \n')
end
local function parse_rpm_branch_type(tag, s)
s = string.match(s, '^%s*([^%s]+)%s*$')
if s ~= 'zstream' then
return nil, tag .. ' must be zstream'
end
return s
end
-- Tests for parse_rpm_branch_type.
do
assert(parse_rpm_branch_type('Branch', ' zstream\n') == 'zstream')
local b, err = parse_rpm_branch_type('Branch', ' \n')
assert(not b and err == 'Branch must be zstream')
local b, err = parse_rpm_branch_type('Branch', ' hotfix\n')
assert(not b and err == 'Branch must be zstream')
end
-- These are the recognized trailer tags in Git commit messages.
-- (Git calls them keys, see git-interpret-trailers(1).)
local recognized_trailer_tags = {
['Resolves']={field='tickets',
parse=parse_ticket_string,
duplicates=true},
['Parent']={parse=parse_parent},
['Patch-Git-Version']={parse=parse_patchgit_version,
need_parent=true,
field='patchgit_version'},
['RPM-Branch-Type']={parse=parse_rpm_branch_type},
['RPM-Skip-Release']={parse=parse_bool},
['RPM-Changelog']={parse=parse_rpm_changelog},
['RPM-Changelog-Stop']={parse=parse_bool,
need_parent=true},
['RPM-Release']={parse=parse_rpm_release,
need_parent=true},
['RPM-Version']={parse=parse_rpm_version,
need_parent=true},
}
recognized_trailer_tags.Related = recognized_trailer_tags.Resolves
for k, v in pairs(recognized_trailer_tags) do
if not v.field then
v.field = string.lower(string.gsub(k, '%-', '_'))
end
end
function parse_trailer(message)
-- This holds due to the format of the input file.
assert(string.sub(message, #message) == '\n')
-- Skip the non-trailer part in the message. There is no reverse
-- find, so call string.find repeatedly, under the assumption that
-- it's simpler and faster than the alternatives.
local pos = 0
do
while true do
-- Only skip one '\n', in case there are more than two.
local npos = string.find(message, '\n\n', pos + 1, true)
if not npos then
if pos == 0 then
-- No '\n\n', no trailer.
return nil, 'missing Git trailer'
end
pos = pos + 2
break
end
pos = npos
end
end
-- pos is the start of the next line to parse.
local result = {}
local tags_seen = {} -- Keys are tag names, not fields. Values are true.
local last_tag
local need_parent -- Name of tag that requires Parent:.
while true do
local tag, contents, npos =
string.match(message, '^([%w-]+):([^\n]*\n)()', pos)
if not npos then
if pos > #message then
break
elseif not last_tag then
-- Probably just a new paragraph in the commit message.
return nil, 'no Git trailer found'
else
return nil, 'malformed line in Git trailer after ' .. last_tag
.. ' tag'
end
end
pos = npos
last_tag = tag
-- Find the tag descriptor.
local descr = recognized_trailer_tags[tag]
if not descr then
return nil, 'not a recognized Git trailer tag: ' .. tag
end
if descr.need_parent then
need_parent = tag
end
if tags_seen[tag] and not descr.duplicates then
return nil, 'duplicate ' .. tag .. ' tag'
end
tags_seen[tag] = true
-- Append indented continuation lines.
while true do
local cont, npos = string.match(message, '^([ \t][^\n]*\n)()', pos)
if not cont then
break
end
pos = npos
-- This has quadratic behavior. Assume that there are few
-- continuation lines.
contents = contents .. cont
end
local value, err = descr.parse(tag, contents, result[descr.field])
if value == nil then
return nil, err
end
result[descr.field] = value
end
if not last_tag then
return nil, 'no Git trailer found'
end
if need_parent and not result.parent then
return nil, need_parent .. ' requires Parent tag'
end
return result
end
-- Tests for parse_trailer.
do
local t, err
t, err = parse_trailer([[Fix memory leak after fdopen seek failure
- Backport: Remove memory leak in fdopen (bug 31840)
- Backport: libio: Test for fdopen memory leak without SEEK_END
support (bug 31840)
Resolves: RHEL-108475
]])
assert(t, err)
assert(t.tickets)
assert(#t.tickets == 1)
assert(t.tickets[1] == 'RHEL-108475')
assert(t.patchgit_version == nil)
assert(t.rpm_version == nil)
assert(t.rpm_release == nil)
assert(t.rpm_changelog_stop == nil)
assert(t.rpm_branch_type == nil)
-- Initial commit.
t, err = parse_trailer([[Switch to patch-git
Resolves: RHEL-111490
Parent: 92dfd986b2f2c697144be2ebe10a27d72c660ba4
Patch-Git-Version: 1
RPM-Version: 2.39
RPM-Release: 60%{?dist}
RPM-Changelog-Stop: yes
]])
assert(t, err)
assert(t.tickets)
assert(#t.tickets == 1)
assert(t.tickets[1] == 'RHEL-111490')
assert(t.patchgit_version == 1)
assert(t.rpm_version == '2.39')
assert(t.rpm_release == '60%{?dist}')
assert(t.rpm_changelog_stop == true)
assert(t.rpm_branch_type == nil)
-- Missing blank line before Resolves:.
t, err = parse_trailer([[Fix memory leak after fdopen seek failure
- Backport: Remove memory leak in fdopen (bug 31840)
- Backport: libio: Test for fdopen memory leak without SEEK_END
support (bug 31840)
Resolves: RHEL-108475
]])
assert(not t)
assert(err == 'no Git trailer found', err)
-- RPM release is set.
t, err = parse_trailer([[Fix memory leak after fdopen seek failure
Resolves: RHEL-108475
Parent: 46a31fdf250a30ae96c082376a8eab95252762c0
RPM-Release: 59%{?dist}
]])
assert(t, err)
assert(t.parent == '46a31fdf250a30ae96c082376a8eab95252762c0')
assert(t.rpm_release == '59%{?dist}')
-- RPM-Version requires Parent and accepts a simple version.
t, err = parse_trailer([[Set version for next release
Resolves: RHEL-108475
RPM-Version: 2.39.1
]])
assert(not t)
assert(err == 'RPM-Version requires Parent tag')
t, err = parse_trailer([[Set version for next release
Resolves: RHEL-108475
Parent: 46a31fdf250a30ae96c082376a8eab95252762c0
RPM-Version: 2.39.1
]])
assert(t, err)
assert(t.rpm_version == '2.39.1')
-- Duplicate RPM-Release:.
t, err = parse_trailer([[Fix memory leak after fdopen seek failure
Resolves: RHEL-108475
RPM-Release: 59%{?dist}
RPM-Release: 59%{?dist}.1
]])
assert(not t)
assert(err == 'duplicate RPM-Release tag', err)
-- Duplicate RPM-Version:.
t, err = parse_trailer([[Set version for next release
Resolves: RHEL-108475
Parent: 46a31fdf250a30ae96c082376a8eab95252762c0
RPM-Version: 2.39.1
RPM-Version: 2.39.2
]])
assert(not t)
assert(err == 'duplicate RPM-Version tag', err)
-- Multiple trailers.
t, err = parse_trailer([[Fix memory leak after fdopen seek failure
- Backport: Remove memory leak in fdopen (bug 31840)
- Backport: libio: Test for fdopen memory leak without SEEK_END
support (bug 31840)
Resolves: RHEL-108475
Resolves: swbz#31840
RPM-Skip-Release: yes
]])
assert(t, err)
assert(#t.tickets, 2)
assert(t.tickets[1] == 'RHEL-108475')
assert(t.tickets[2] == 'swbz#31840')
assert(not t.rpm_release)
assert(t.rpm_skip_release == true)
-- Invalid value for Resolves trailer.
t, err = parse_trailer([[Fix memory leak after fdopen seek failure
- Backport: Remove memory leak in fdopen (bug 31840)
- Backport: libio: Test for fdopen memory leak without SEEK_END
support (bug 31840)
Resolves: RHEL-108475
Related:
RPM-Release: no
]])
assert(not t)
assert(err == 'no ticket references found in Related')
-- Broken line in trailer.
t, err = parse_trailer([[Fix memory leak after fdopen seek failure
- Backport: Remove memory leak in fdopen (bug 31840)
- Backport: libio: Test for fdopen memory leak without SEEK_END
support (bug 31840)
Resolves: RHEL-108475
swbz#31840
RPM-Release: no
]])
assert(not t)
assert(err == 'malformed line in Git trailer after Resolves tag')
end
end
-- Produce a list of changelog messages. The list can be empty. Use
-- trailer.rpm_changelog if available.
local function rpm_changelog_default(message, trailer)
if trailer.rpm_changelog then
return trailer.rpm_changelog
end
local subject = assert(string.match(message, '^%s*([^\n]-)%s*\n'))
-- See if there are tickets listed in parentheses.
local parens = string.match(subject, '%s+[(]([^)]+)[)]%s*$')
local tickets = trailer.tickets
if (not (parens and parse_ticket_string('subject', parens))
and tickets and #tickets > 0) then
subject = subject .. ' (' .. table.concat(tickets, ', ') .. ')'
end
local result = {}
append_to_rpm_changelog(subject, result)
return result
end
-- Tests for rpm_changelog_default.
do
local t
local function rcd(message)
return rpm_changelog_default(message, assert(parse_trailer(message)))
end
t = rcd([[Remove memory leak in fdopen
Resolves: RHEL-108475
]])
assert_eq(t, {'- Remove memory leak in fdopen (RHEL-108475)'})
t = rcd([[Remove memory leak in fdopen (RHEL-108475)
Resolves: RHEL-108475
]])
assert_eq(t, {'- Remove memory leak in fdopen (RHEL-108475)'})
t = rcd([[Remove memory leak in fdopen (RHEL-108475)
Resolves: RHEL-108475
RPM-Changelog: -
]])
assert(#t == 0)
t = rcd([[Remove memory leak in fdopen
Resolves: RHEL-108475
RPM-Changelog:
- Remove memory leak in fdopen (bug 31840)
- libio: Test for fdopen memory leak without SEEK_END
]])
assert_eq(t,
{'- Remove memory leak in fdopen (bug 31840)',
'- libio: Test for fdopen memory leak without SEEK_END'})
t = rcd([[Do not wrap the cat
Resolves: RHEL-108475
RPM-Changelog:
- Do not wrap the cat!
/\_/\
( o.o )
> ^ <
- Thank you.
]])
assert_eq(t,
{'- Do not wrap the cat!',
[[ /\_/\]],
[[ ( o.o )]],
[[ > ^ <]],
'- Thank you.'})
t = rcd([[Do not wrap the cat
Resolves: RHEL-108475
RPM-Changelog:
- Do not wrap the cat!
/\_/\
( o.o )
> ^ <
- Thank you.
]])
assert_eq(t,
{'- Do not wrap the cat!',
[[ /\_/\]],
[[ ( o.o )]],
[[ > ^ <]],
'- Thank you.'})
t = rcd([[Do not wrap the cat
Resolves: RHEL-108475
RPM-Changelog:
- Do not wrap the cat!
/\_/\
( o.o )
> ^ <
]])
assert_eq(t,
{[[- Do not wrap the cat!]],
[[ /\_/\]],
[[ ( o.o )]],
[[ > ^ <]]})
-- Variant that has the dash on the RPM-Changelog line.
t = rcd([[Do not wrap the cat
Resolves: RHEL-108475
RPM-Changelog: - Do not wrap the cat!
/\_/\
( o.o )
> ^ <
]])
assert_eq(t,
{[[- Do not wrap the cat!]],
[[ /\_/\]],
[[ ( o.o )]],
[[ > ^ <]]})
end
-- Turns a commit message into a string for diagnostic purposes.
local function commit_to_string(commit)
local result = {
'commit ' .. commit.commit .. '\n',
'Author: ' .. commit.author .. '\n',
'Date: ' .. commit.author_date .. '\n',
'\n',
}
-- Indent the commit message by four spaces.
for line in string.gmatch(commit.message, '([^\n]*\n)') do
result[#result + 1] = ' ' .. line
end
return table.concat(result)
end
-- Abort execution after logging the commit message to stderr.
local function assert_commit(commit, cond, ...)
if cond then
-- Return all function arguments except the first.
return cond, ...
end
local message = ... -- Extract first variadic argument.
local err = message or 'assertion failure'
io.stderr:write('error in commit message: ' .. err .. '\n\n'
.. commit_to_string(commit)
.. '\n')
error(err)
end
-- Test for assert_commit.
assert(3 == #{assert_commit(false, 1, 2, 3)})
-- Go through the relevant commits in patchgit.commits. Set
-- patchgit.start_commit_index_for_changelog and
-- patchgit.start_commit_index for future use by process_commits below.
local function parse_commit_messages()
-- If already invoked, do not parse the commits again.
if patchgit.start_commit_index then
return
end
local commits = patchgit.commits
-- Cache the parsed trailers for forward traversal.
local trailers = {}
local start_commit = 1
local patchgit_version
-- These are set to true once enough commits have been found to
-- compute their values.
local version_known = false
local release_known = false
local changelog_known = false
for i=#patchgit.commits,1,-1 do
local commit = patchgit.commits[i]
local trailer = assert_commit(commit, parse_trailer(commit.message))
if trailer.parent then
if i == 1 then
assert_commit(commit, false, 'Parent tag in commit without parent')
elseif commits[i - 1].commit ~= trailer.parent then
assert_commit(commit, false, 'found unexpected parent commit '
.. commits[i - 1].commit)
end
end
trailers[i] = trailer
patchgit_version = trailer.patchgit_version
if patchgit_version then
assert_commit(commit, patchgit_version == 1,
'unsupport patch-git version ' .. patchgit_version)
end
-- Stop iterating if all data can be determined from the commits seen.
if trailer.rpm_version then
version_known = true
end
if trailer.rpm_release then
release_known = true
end
if trailer.rpm_changelog_stop then
changelog_known = true
if not patchgit.start_commit_index_for_changelog then
patchgit.start_commit_index_for_changelog = i
end
end
-- A Patch-Git-Version commit on its own does not tell us how to
-- interpret previous history. The first commit setting version/release
-- must also set the patch-git version.
if patchgit_version
and patchgit_version and release_known and changelog_known
then
start_commit = i
break
end
end
assert_commit(commits[1], patchgit_version, 'RPM version not determined')
assert_commit(commits[1], version_known, 'RPM version not determined')
assert_commit(commits[1], release_known, 'RPM release not determined')
assert(patchgit.start_commit_index_for_changelog)
patchgit.start_commit_index = start_commit
patchgit.commit_trailers = trailers
end
--
-- Returns a YYYY-MM-DD formatted date as the second result.
local git_date_to_rpm_date
do
local months = {
Jan=1,
Feb=2,
Mar=3,
Apr=4,
May=5,
Jun=6,
Jul=7,
Aug=8,
Sep=9,
Oct=10,
Nov=11,
Dec=12,
}
function git_date_to_rpm_date(s)
local wd, mon, d, y = string.match(
s, '^([A-z][a-z][a-z]) ([A-Z][a-z][a-z]) (%d+) %d%d:%d%d:%d%d (%d+)')
assert(y, s)
local m = assert(months[mon], s)
local rpmdate = string.format('%s %s %02d %04d', wd, mon, d, y)
local ymd = string.format('%04d-%02d-%02d', y, m, d)
return rpmdate, ymd
end
end
-- Tests for git_date_to_rpm_date.
do
local rpmdate, ymd
local rpmdate, ymd = assert(git_date_to_rpm_date(
'Wed Jul 23 09:14:49 2025 +0200'))
assert(rpmdate == 'Wed Jul 23 2025')
assert(ymd == '2025-07-23')
end
-- Quote RPM macro invocations in s and return the string.
local function rpm_quote(s)
-- Parentheses are needed to elide the second return value of string.gsub.
return (string.gsub(s, '%%', '%%%%'))
end
-- Tests for rpm_quote.
do
assert(rpm_quote('') == '')
assert(rpm_quote('abc') == 'abc')
assert(rpm_quote('%abc') == '%%abc')
assert(rpm_quote('a%bc') == 'a%%bc')
assert(rpm_quote('ab%c') == 'ab%%c')
assert(rpm_quote('abc%') == 'abc%%')
assert(rpm_quote('%%abc') == '%%%%abc')
assert(rpm_quote('a%%bc') == 'a%%%%bc')
assert(rpm_quote('ab%%c') == 'ab%%%%c')
assert(rpm_quote('abc%%') == 'abc%%%%')
end
-- If changelog is not nil, it is used as a table of tables for
-- changelog entries.
local function process_commits(changelog, changelog_after_commit)
parse_commit_messages()
local commits = patchgit.commits
local start_changelog = assert(patchgit.start_commit_index_for_changelog)
local trailers = assert(patchgit.commit_trailers)
local rpm_version
-- rpm_release_num is the rightmost number that needs to be
-- incremented for new RPM releases. Call set_release to change
-- and get_release to read.
local rpm_release_pre, rpm_release_num, rpm_release_post
local function set_release(rel)
rpm_release_pre, rpm_release_num, rpm_release_post =
assert_commit(commit, string.match(rel, '^(.-)(%d+)([^%d]*)$'))
end
local function get_release()
return rpm_release_pre .. rpm_release_num .. rpm_release_post
end
local on_zstream = false
local last_changelog_rpmdate
local last_changelog_ymd
-- This is set to true once changelog_after_commit is found
-- as a commit hash.
local include_commits_in_changelog = not changelog_after_commit
assert(not changelog_after_commit or #changelog_after_commit == 40)
for i=assert(patchgit.start_commit_index), #commits do
local commit = commits[i]
local trailer = trailers[i]
local zstream_switch_request = trailer.rpm_branch_type == 'zstream'
if trailer.rpm_version then
rpm_version = trailer.rpm_version
-- The version gets incremented below.
if on_zstream or zstream_switch_request then
set_release('1%{?dist}.0')
zstream_switch_request = false
else
set_release('0%{?dist}')
end
end
if trailer.rpm_release then
set_release(trailer.rpm_release)
assert_commit(commit, get_release() == trailer.rpm_release,
'RPM release parsing')
on_zstream = zstream_switch_request
-- Do not bump the release below.
else
if zstream_switch_request and not on_zstream then
assert_commit(commit, rpm_release_num,
'RPM release not determined for zstream')
-- ZStream NVR policy: Release counter follows %{?dist}.
set_release(get_release() .. '.0')
on_zstream = true
end
if not trailer.rpm_skip_release then
assert_commit(commit, rpm_release_num, 'RPM release not determined')
rpm_release_num = rpm_release_num + 1
end
end
if changelog then
if not include_commits_in_changelog then
if commit.commit == changelog_after_commit then
-- Start including commits after this one.
include_commits_in_changelog = true
end
else
local cl_entries = assert_commit(
commit, rpm_changelog_default(commit.message, trailer))
if #cl_entries > 0 then
-- The changelog list where the new entries are inserted.
local target_cl = changelog[#changelog]
if not trailer.rpm_skip_release then
-- Changelog is not skipped. Generate a new header.
-- Use commit date because it gets updated when
-- cherry-picking. Avoid going backwards in time
-- because it generates warnings from RPM.
local rpmdate, ymd = git_date_to_rpm_date(commit.commit_date)
if last_changelog_ymd and ymd < last_changelog_ymd then
rpmdate = last_changelog_rpmdate
ymd = last_changelog_ymd
end
local author = commit.author
-- The %changelog section does not include the %dist macro.
local rpmrel = string.gsub(get_release(), '%%{%?dist}', '')
assert(not string.find(rpmrel, '%', 1, true), rpmrel)
local hdr = '* ' .. rpmdate .. ' ' .. author .. ' - '
.. rpm_version .. '-' .. rpmrel
-- Append a new changelog list.
target_cl = {hdr}
changelog[#changelog + 1] = target_cl
end
-- Insert the changelog entries into the previous
-- changelog, at the end. No direct move because of %
-- quoting. If no new entry was inserted above, but we
-- switched to zstream, this adds to an entry that does not
-- have the expected release, which is a minor inconsistency.
assert_commit(commit,
target_cl and #target_cl > 0,
'first commit skips changelog and has an entry')
table.move(cl_entries, 1, #cl_entries,
#target_cl + 1, target_cl)
end
end
end
end
assert_commit(commits[#commits], rpm_version,
'RPM version not determined for HEAD')
assert_commit(commits[#commits], rpm_release_num,
'RPM release not determined for HEAD')
patchgit.rpm_version = rpm_version
patchgit.rpm_release = get_release()
end
----------------------------------------------------------------------
-- Launching the actual work, and command line handling
----------------------------------------------------------------------
if rpm then
-- If running under rpm, generate the missing parts of the spec file.
generate_files()
parse_commits()
function patchgit.release()
process_commits()
print(rpm.expand(patchgit.rpm_release))
end
function patchgit.version()
process_commits()
print(rpm.expand(patchgit.rpm_version))
end
function patchgit.changelog()
-- This can be defined to extract the final set of patches from
-- the spec file in a programmable manner. It is a replacement
-- for rpmspec --eval, which does not work as expected because
-- it is not evaluated in the context of the patch file. Do
-- this from the changelog writing procedure because at this
-- point, all Patch: directives in the spec file definitely have
-- been processed, so the patches global variable has been
-- populated.
local patches_log = rpm.expand('%{?_patchgit_log_patches}')
if patches_log ~= '' then
local fp = assert(io.open(patches_log, 'w+'))
for i=1,#patches do
local pname = assert(string.match(patches[i], '.*/([^/]+)$'))
fp:write('Patch' .. i .. ': ' .. pname .. '\n')
end
assert(fp:close())
end
local changelog = {}
process_commits(changelog)
for i=#changelog,1,-1 do
local cl = changelog[i]
for j=1,#cl do
-- RPM does not recursively macro-expand what we emit here,
-- so do not apply %-escaping.
print(cl[j], '\n')
end
print('\n')
end
end
else
local args = {...}
if #args == 0 then
args[1] = 'help'
end
local cmds = {}
function cmds.help()
print([[Available subcommands:
help this output
patches print list of PatchNNN: directives
version print the value of the computed RPM version at HEAD
release print the value of the computed RPM release at HEAD
verrel print the value of RPM version-release
changelog show the auto-generated changelog entries]])
end
function cmds.patches(flag, extra)
if extra then
error('unrecognized argument: ' .. extra)
end
if flag and flag ~= '--history-only' then
error('unrecognized argument: ' .. flag)
end
generate_files()
parse_commits()
if flag == '--history-only' then
-- Only print the patches that come from the Git history.
patchgit.patches({history_only=true})
else
-- There does not seem to be a way to do this in a better way.
-- Instruct one of the patch-git macros to write a temporary file.
with_temporary_file(
function(tmpfile)
check_rpmspec(get_single_spec_file(),
'-D _patchgit_log_patches ' .. tmpfile,
'-q', '--srpm', '--qf', '')
local fp = assert(io.open(tmpfile, 'r'))
assert(io.stdout:write(assert(fp:read('a'))))
assert(fp:close())
end)
end
end
function cmds.version()
generate_files()
parse_commits()
process_commits()
print(patchgit.rpm_version)
end
function cmds.release()
generate_files()
parse_commits()
process_commits()
print(patchgit.rpm_release)
end
function cmds.verrel()
generate_files()
parse_commits()
process_commits()
print(patchgit.rpm_version .. '-' .. patchgit.rpm_release)
end
function cmds.changelog(start_commit)
if start_commit then
start_commit =
assert(string.match(run_git('rev-parse '
.. shell_quote(start_commit)),
'^([^\n]+)\n'))
end
generate_files()
parse_commits()
local changelog = {}
process_commits(changelog, start_commit)
for i=#changelog,1,-1 do
local cl = changelog[i]
for j=1,#cl do
-- Apply %-escaping here, so that the output can be copy-pasted
-- into %changelog.
print(rpm_quote(cl[j]))
end
print()
end
end
function cmds.selftest()
-- Hidden command to run all subcommands.
local test_commands = {'patches --history-only',
'changelog HEAD^'}
for k, _ in pairs(cmds) do
if k ~= 'selftest' then
test_commands[#test_commands + 1] = k
end
end
table.sort(test_commands)
local failure
for _, cmd in ipairs(test_commands) do
cmd = 'lua patch-git.lua ' .. cmd
print('* ' .. cmd)
local ok, term, status = os.execute(cmd)
if not ok or term ~= 'exit' or status ~= 0 then
failure = true
print('FAIL: term=' .. term .. ', status=' .. status)
end
end
if fail then
os.exit(1)
end
end
local cmd = cmds[args[1]]
if not cmd then
io.stderr:write('usage: Unrecognized command "' .. args[1]
.. '". Use "help" for a list of commands.\n')
os.exit(1)
end
cmd(table.unpack(args, 2))
end