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