xref: /freebsd/tools/tools/git/mfc-candidates.lua (revision af445e1dd95d43c011f377ea6e6618637fecc61c)
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 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 = nil
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		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