xref: /freebsd/tools/tools/git/mfc-candidates.lua (revision ba120b8d38f3f0bc78efa5200807eb9904fbb1f9)
1#!/usr/libexec/flua
2
3-- SPDX-License-Identifier: BSD-2-Clause
4-- Copyright 2024 The FreeBSD Foundation
5
6-- MFC candidate search utility.  Identify hashes that exist only in the
7-- "MFC from" branch and do not have a corresponding "cherry picked from"
8-- commit in the "MFC to" branch.
9
10-- Global state
11local verbose = 0
12
13-- Execute a command and return its output.  A final newline is stripped,
14-- similar to sh.
15local function exec_command(command)
16	local handle = assert(io.popen(command))
17	local output = handle:read("a")
18	handle:close()
19	if output:sub(-1) == "\n" then
20		return output:sub(1, -2)
21	end
22	return output
23end
24
25-- Return a table of cherry-pick (MFC) candidates.
26local function read_from(from_branch, to_branch, author, dirspec)
27	local command = "git rev-list --first-parent --reverse "
28	command = command .. to_branch .. ".." .. from_branch
29	if #author > 0 then
30		command = command .. " --committer \\<" .. author .. "@"
31	end
32	if dirspec then
33		command = command .. " " .. dirspec
34	end
35	if verbose > 1 then
36		print("Obtaining MFC-from commits using command:")
37		print(command)
38	end
39	local handle = assert(io.popen(command))
40	local content = {}
41	for line in handle:lines() do
42		table.insert(content, line)
43	end
44	handle:close()
45	return content
46end
47
48-- Return a table of original hashes of changes that have already been
49-- cherry-picked (MFC'd).
50local function read_to(from_branch, to_branch, dirspec)
51	local command = "git log " .. from_branch .. ".." .. to_branch
52	command = command .. " --grep 'cherry picked from'"
53	if dirspec then
54		command = command .. " " .. dirspec
55	end
56	if verbose > 1 then
57		print("Obtaining MFC-to commits using command:")
58		print(command)
59	end
60	local handle = assert(io.popen(command))
61	local content = {}
62	for line in handle:lines() do
63		local hash = line:match("%(cherry picked from commit ([0-9a-f]+)%)")
64		if hash then
65			table.insert(content, hash)
66		end
67	end
68	handle:close()
69	return content
70end
71
72-- Read a commit exclude file and return its content as a table.  Comments
73-- starting with # and text after a hash is ignored.
74local function read_exclude(filename)
75	local file = assert(io.open(filename, "r"))
76	local content = {}
77	for line in file:lines() do
78		local hash = line:match("^%x+")
79		if hash then
80			-- Hashes are 40 chars; if less, expand short hash.
81			if #hash < 40 then
82				hash = exec_command(
83				    "git rev-parse " .. hash)
84			end
85			table.insert(content, hash)
86		end
87	end
88	file:close()
89	return content
90end
91
92--- Remove hashes from 'set1' list that are present in 'set2' list
93local function set_difference(set1, set2)
94	local set2_values = {}
95	for _, value in ipairs(set2) do
96		set2_values[value] = true
97	end
98
99	local result = {}
100	for _, value in ipairs(set1) do
101		if not set2_values[value] then
102			table.insert(result, value)
103		end
104	end
105	return result
106end
107
108local function params(from_branch, to_branch, author)
109	print("from:             " .. from_branch)
110	print("to:               " .. to_branch)
111	if #author > 0 then
112		print("author/committer: " .. author)
113	else
114		print("author/committer: <all>")
115	end
116end
117
118local function usage(from_branch, to_branch, author)
119	local script_name = arg[0]:match("([^/]+)$")
120	print(script_name .. " [-ah] [-F git-show-fmt] [-f from_branch] [-t to_branch] [-u user] [-X exclude_file] [path ...]")
121	print()
122	params(from_branch, to_branch, author)
123end
124
125-- Main function
126local function main()
127	local from_branch = "freebsd/main"
128	local to_branch
129	local author = os.getenv("USER") or ""
130	local dirspec
131
132	local url = exec_command("git remote get-url freebsd 2>/dev/null")
133	local freebsd_repo
134	if url and url ~= "" then
135		freebsd_repo = string.match(url, "[^:/]+$")
136		freebsd_repo = string.gsub(freebsd_repo, "%.git$", "")
137	end
138	if freebsd_repo == "ports" or freebsd_repo == "freebsd-ports" then
139		local year = os.date("%Y")
140		local month = os.date("%m")
141		local qtr = math.ceil(month / 3)
142		to_branch = "freebsd/" .. year .. "Q" .. qtr
143	elseif freebsd_repo == "src" or freebsd_repo == "freebsd-src" then
144		-- If pwd is a stable or release branch tree, default to it.
145		local cur_branch = exec_command("git symbolic-ref --short HEAD")
146		if string.match(cur_branch, "^stable/") then
147			to_branch = cur_branch
148		elseif string.match(cur_branch, "^releng/") then
149			to_branch = cur_branch
150			local major = string.match(cur_branch, "%d+")
151			from_branch = "freebsd/stable/" .. major
152		else
153			-- Use latest stable branch.
154			to_branch = exec_command("git for-each-ref --sort=-v:refname " ..
155				"--format='%(refname:lstrip=2)' " ..
156				"refs/remotes/freebsd/stable/* --count=1")
157		end
158	else
159		print("pwd is not under a ports or src repository.")
160		return
161	end
162
163	local do_help = false
164	local exclude_file = nil
165	local gitshowfmt = '%h %s'
166	local i = 1
167	while i <= #arg and arg[i] do
168		local opt = arg[i]
169		if opt == "-a" then
170			author = ""
171		elseif opt == "-f" then
172			from_branch = arg[i + 1]
173			i = i + 1
174		elseif opt == "-h" then
175			do_help = true
176			i = i + 1
177		elseif opt == "-t" then
178			to_branch = arg[i + 1]
179			i = i + 1
180		elseif opt == "-u" then
181			author = arg[i + 1]
182			i = i + 1
183		elseif opt == "-v" then
184			verbose = verbose + 1
185		elseif opt == "-F" then
186			gitshowfmt = arg[i + 1]
187			i = i + 1
188		elseif opt == "-X" then
189			exclude_file = arg[i + 1]
190			i = i + 1
191		else
192			break
193		end
194		i = i + 1
195	end
196
197	if do_help then
198		usage(from_branch, to_branch, author)
199		return
200	end
201
202	if arg[i] then
203		dirspec = arg[i]
204		--print("dirspec = " .. dirspec)
205		-- XXX handle multiple dirspecs?
206	end
207
208	if verbose > 0 then
209		params(from_branch, to_branch, author)
210	end
211
212	local from_hashes = read_from(from_branch, to_branch, author, dirspec)
213	local to_hashes = read_to(from_branch, to_branch, dirspec)
214
215	local result_hashes = set_difference(from_hashes, to_hashes)
216
217	if exclude_file then
218		local exclude_hashes = read_exclude(exclude_file)
219		result_hashes = set_difference(result_hashes, exclude_hashes)
220	end
221
222	-- Print the result
223	for _, hash in ipairs(result_hashes) do
224		print(exec_command("git show --pretty='" .. gitshowfmt .. "' --no-patch " .. hash))
225	end
226end
227
228main()
229