-- 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 . --[==[ 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