Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

347 linesCodeBlameRaw
1//! What a merge or squash commit says unless its merger writes it: the
2//! repository's choice (`RepoSettings::merge_commit_message` and
3//! `squash_commit_message`), filled in from the pull request. A rebase and
4//! a fast-forward make no commit of their own, so say nothing.
5
6use g1t_contracts::rules::MergeMethod;
7use g1t_contracts::work::{CommitMessageDefault, RepoSettings};
8
9/// The longest commit title a merger may write.
10pub(crate) const MAX_TITLE_CHARS: usize = 500;
11/// The longest commit message body a merger may write.
12pub(crate) const MAX_MESSAGE_CHARS: usize = 64_000;
13
14/// One commit the pull request adds, as its message says.
15#[derive(Clone, Debug, Default, PartialEq, Eq)]
16pub(crate) struct CommitText {
17 pub message: String,
18 pub author_email: Option<String>,
19}
20
21impl CommitText {
22 /// Its message's first line.
23 pub(crate) fn subject(&self) -> &str {
24 self.message.lines().next().unwrap_or_default().trim()
25 }
26}
27
28/// What a message is made from.
29#[derive(Clone, Debug, Default)]
30pub(crate) struct MessageFacts<'a> {
31 pub number: u32,
32 pub title: &'a str,
33 pub body: &'a str,
34 /// The branch holding the change.
35 pub branch: &'a str,
36 /// Who the branch belongs to: the workspace of the repository or fork
37 /// it is in, or for a working copy, whoever asked for the change.
38 pub head_owner: &'a str,
39 /// The repository or fork holding it, `owner/name`.
40 pub head_repo: &'a str,
41 /// The branch it merges into.
42 pub base: &'a str,
43 /// Its author's username.
44 pub author: &'a str,
45 /// Its commits, oldest first, merge commits left out.
46 pub commits: &'a [CommitText],
47 /// The issues it closes.
48 pub issues: &'a [u32],
49}
50
51/// Whether making the default message for `method` needs the pull
52/// request's commits read.
53pub(crate) fn needs_commits(method: MergeMethod, settings: &RepoSettings) -> bool {
54 let (choice, template) = match method {
55 MergeMethod::Merge => (settings.merge_commit_message, settings.merge_commit_template.as_deref()),
56 MergeMethod::Squash => (settings.squash_commit_message, settings.squash_commit_template.as_deref()),
57 MergeMethod::Rebase | MergeMethod::FastForward => return false,
58 };
59 match choice {
60 CommitMessageDefault::Default => method == MergeMethod::Squash,
61 CommitMessageDefault::PullTitleAndCommits => method == MergeMethod::Squash,
62 CommitMessageDefault::Custom => template.is_some_and(|text| text.contains("{commits}") || text.contains("{co_authors}")),
63 CommitMessageDefault::PullTitle | CommitMessageDefault::PullTitleAndDescription => false,
64 }
65}
66
67/// The default title and body of the commit `method` makes, or `None` for
68/// one that makes none of its own.
69pub(crate) fn default_message(method: MergeMethod, settings: &RepoSettings, facts: &MessageFacts<'_>) -> Option<(String, String)> {
70 let numbered = format!("{} (#{})", facts.title.trim(), facts.number);
71 let description = facts.body.trim().to_owned();
72 match method {
73 MergeMethod::Rebase | MergeMethod::FastForward => None,
74 MergeMethod::Merge => Some(match settings.merge_commit_message {
75 CommitMessageDefault::PullTitle => (numbered, String::new()),
76 CommitMessageDefault::PullTitleAndDescription => (numbered, description),
77 CommitMessageDefault::Custom => custom(settings.merge_commit_template.as_deref(), facts).unwrap_or_else(|| merge_default(facts)),
78 // Listing commits is for squashes; settings refuse it here.
79 CommitMessageDefault::Default | CommitMessageDefault::PullTitleAndCommits => merge_default(facts),
80 }),
81 MergeMethod::Squash => Some(match settings.squash_commit_message {
82 CommitMessageDefault::PullTitle => (numbered, String::new()),
83 CommitMessageDefault::PullTitleAndDescription => (numbered, description),
84 CommitMessageDefault::PullTitleAndCommits => (
85 numbered,
86 facts.commits.iter().map(|commit| commit.message.trim()).filter(|message| !message.is_empty()).collect::<Vec<_>>().join("\n\n"),
87 ),
88 CommitMessageDefault::Custom => custom(settings.squash_commit_template.as_deref(), facts).unwrap_or_else(|| squash_default(facts)),
89 CommitMessageDefault::Default => squash_default(facts),
90 }),
91 }
92}
93
94/// "Merge pull request #12 from owner/branch", and its title below.
95fn merge_default(facts: &MessageFacts<'_>) -> (String, String) {
96 (
97 format!("Merge pull request #{} from {}/{}", facts.number, facts.head_owner, facts.branch),
98 facts.title.trim().to_owned(),
99 )
100}
101
102/// Its title and number; below, its commits' subjects when it has more
103/// than one.
104fn squash_default(facts: &MessageFacts<'_>) -> (String, String) {
105 let body = if facts.commits.len() > 1 { bullets(facts.commits) } else { String::new() };
106 (format!("{} (#{})", facts.title.trim(), facts.number), body)
107}
108
109fn bullets(commits: &[CommitText]) -> String {
110 commits
111 .iter()
112 .map(CommitText::subject)
113 .filter(|subject| !subject.is_empty())
114 .map(|subject| format!("* {subject}"))
115 .collect::<Vec<_>>()
116 .join("\n")
117}
118
119/// `Co-authored-by:` lines for the commits' authors, each once.
120fn co_authors(commits: &[CommitText]) -> String {
121 let mut seen: Vec<String> = Vec::new();
122 for email in commits.iter().filter_map(|commit| commit.author_email.as_deref()) {
123 let email = email.trim().to_lowercase();
124 if !email.is_empty() && !seen.contains(&email) {
125 seen.push(email);
126 }
127 }
128 seen.iter()
129 .map(|email| format!("Co-authored-by: {} <{email}>", email.split('@').next().unwrap_or(email)))
130 .collect::<Vec<_>>()
131 .join("\n")
132}
133
134/// The repository's template filled in: its first line is the title, the
135/// rest the body. `None` when there is no template or it says nothing.
136fn custom(template: Option<&str>, facts: &MessageFacts<'_>) -> Option<(String, String)> {
137 let issues = facts.issues.iter().map(|number| format!("#{number}")).collect::<Vec<_>>().join(", ");
138 let filled = fill(
139 template?,
140 &[
141 ("title", facts.title.trim()),
142 ("number", &facts.number.to_string()),
143 ("body", facts.body.trim()),
144 ("branch", facts.branch),
145 ("head_repo", facts.head_repo),
146 ("base", facts.base),
147 ("author", facts.author),
148 ("commits", &bullets(facts.commits)),
149 ("issues", &issues),
150 ("co_authors", &co_authors(facts.commits)),
151 ],
152 );
153 let (title, body) = split(&filled);
154 (!title.is_empty()).then_some((title, body))
155}
156
157/// `template` with each `{name}` replaced once, left to right: what a
158/// value holds is never filled in again. Unknown names stay as written.
159fn fill(template: &str, values: &[(&str, &str)]) -> String {
160 let mut out = String::with_capacity(template.len());
161 let mut rest = template;
162 while let Some(open) = rest.find('{') {
163 out.push_str(&rest[..open]);
164 let after = &rest[open + 1..];
165 let named = after.find('}').map(|close| (&after[..close], &after[close + 1..]));
166 match named.and_then(|(name, tail)| values.iter().find(|(key, _)| *key == name).map(|(_, value)| (*value, tail))) {
167 Some((value, tail)) => {
168 out.push_str(value);
169 rest = tail;
170 }
171 None => {
172 out.push('{');
173 rest = after;
174 }
175 }
176 }
177 out.push_str(rest);
178 out
179}
180
181/// A whole message as its title (first line) and body (the rest, blank
182/// lines around it dropped).
183pub(crate) fn split(message: &str) -> (String, String) {
184 let message = message.trim();
185 match message.split_once('\n') {
186 Some((title, body)) => (title.trim().to_owned(), body.trim().to_owned()),
187 None => (message.to_owned(), String::new()),
188 }
189}
190
191/// A title and body as one commit message.
192pub(crate) fn compose(title: &str, body: &str) -> String {
193 let (title, body) = (title.trim(), body.trim());
194 if body.is_empty() { title.to_owned() } else { format!("{title}\n\n{body}") }
195}
196
197/// The message a merger wrote, over the default: a title they give
198/// replaces the default's, a body they give (even empty) replaces its body.
199/// Or why it cannot be used.
200pub(crate) fn chosen(
201 default: Option<(String, String)>,
202 title: Option<&str>,
203 body: Option<&str>,
204) -> std::result::Result<Option<String>, &'static str> {
205 let Some((default_title, default_body)) = default else {
206 return Ok(None);
207 };
208 let title = match title.map(str::trim) {
209 Some("") => return Err("A commit title is required."),
210 Some(title) if title.chars().count() > MAX_TITLE_CHARS => return Err("That commit title is too long."),
211 Some(title) if title.contains('\n') => return Err("A commit title is one line; put the rest in the message."),
212 Some(title) => title.to_owned(),
213 None => default_title,
214 };
215 let body = match body {
216 Some(body) if body.chars().count() > MAX_MESSAGE_CHARS => return Err("That commit message is too long."),
217 Some(body) => body.to_owned(),
218 None => default_body,
219 };
220 Ok(Some(compose(&title, &body)))
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn commit(message: &str, email: &str) -> CommitText {
228 CommitText { message: message.into(), author_email: Some(email.into()) }
229 }
230
231 fn facts<'a>(commits: &'a [CommitText], issues: &'a [u32]) -> MessageFacts<'a> {
232 MessageFacts {
233 number: 12,
234 title: "Add forks ",
235 body: "Forks are ordinary repositories.\n",
236 branch: "forks",
237 head_owner: "ana",
238 head_repo: "ana/g1t",
239 base: "main",
240 author: "ana",
241 commits,
242 issues,
243 }
244 }
245
246 fn with(merge: CommitMessageDefault, squash: CommitMessageDefault) -> RepoSettings {
247 RepoSettings { merge_commit_message: merge, squash_commit_message: squash, ..RepoSettings::default() }
248 }
249
250 fn message(method: MergeMethod, settings: &RepoSettings, facts: &MessageFacts<'_>) -> (String, String) {
251 default_message(method, settings, facts).expect("a commit of its own")
252 }
253
254 #[test]
255 fn a_merge_commit_names_where_it_came_from_by_default() {
256 let none = [];
257 let settings = RepoSettings::default();
258 assert_eq!(
259 message(MergeMethod::Merge, &settings, &facts(&none, &[])),
260 ("Merge pull request #12 from ana/forks".into(), "Add forks".into())
261 );
262 let titled = with(CommitMessageDefault::PullTitle, CommitMessageDefault::Default);
263 assert_eq!(message(MergeMethod::Merge, &titled, &facts(&none, &[])), ("Add forks (#12)".into(), String::new()));
264 let described = with(CommitMessageDefault::PullTitleAndDescription, CommitMessageDefault::Default);
265 assert_eq!(
266 message(MergeMethod::Merge, &described, &facts(&none, &[])),
267 ("Add forks (#12)".into(), "Forks are ordinary repositories.".into())
268 );
269 }
270
271 #[test]
272 fn a_squash_lists_its_commits_when_it_has_more_than_one() {
273 let settings = RepoSettings::default();
274 let one = [commit("Add forks\n\nLong story.", "ana@example.com")];
275 assert_eq!(message(MergeMethod::Squash, &settings, &facts(&one, &[])), ("Add forks (#12)".into(), String::new()));
276 let two = [commit("Add forks\n\nLong story.", "ana@example.com"), commit("Fix the test", "bo@example.com")];
277 assert_eq!(
278 message(MergeMethod::Squash, &settings, &facts(&two, &[])),
279 ("Add forks (#12)".into(), "* Add forks\n* Fix the test".into())
280 );
281 let all = with(CommitMessageDefault::Default, CommitMessageDefault::PullTitleAndCommits);
282 assert_eq!(
283 message(MergeMethod::Squash, &all, &facts(&two, &[])),
284 ("Add forks (#12)".into(), "Add forks\n\nLong story.\n\nFix the test".into())
285 );
286 let described = with(CommitMessageDefault::Default, CommitMessageDefault::PullTitleAndDescription);
287 assert_eq!(message(MergeMethod::Squash, &described, &facts(&two, &[])).1, "Forks are ordinary repositories.");
288 }
289
290 #[test]
291 fn rebases_and_fast_forwards_make_no_message() {
292 let settings = RepoSettings::default();
293 assert!(default_message(MergeMethod::Rebase, &settings, &facts(&[], &[])).is_none());
294 assert!(default_message(MergeMethod::FastForward, &settings, &facts(&[], &[])).is_none());
295 assert!(!needs_commits(MergeMethod::Rebase, &settings));
296 }
297
298 #[test]
299 fn a_template_is_filled_in_once() {
300 let commits = [commit("One", "ana@example.com"), commit("Two", "Ana@Example.com"), commit("Three", "bo@example.com")];
301 let settings = RepoSettings {
302 squash_commit_message: CommitMessageDefault::Custom,
303 squash_commit_template: Some(
304 "{title} (#{number}) into {base}\n\n{body}\nFrom {head_repo}:{branch} by {author}\n{commits}\nCloses {issues}\n{co_authors}\n{unknown} {title"
305 .into(),
306 ),
307 ..RepoSettings::default()
308 };
309 let (title, body) = message(MergeMethod::Squash, &settings, &facts(&commits, &[3, 4]));
310 assert_eq!(title, "Add forks (#12) into main");
311 assert_eq!(
312 body,
313 "Forks are ordinary repositories.\nFrom ana/g1t:forks by ana\n* One\n* Two\n* Three\nCloses #3, #4\nCo-authored-by: ana <ana@example.com>\nCo-authored-by: bo <bo@example.com>\n{unknown} {title"
314 );
315 assert!(needs_commits(MergeMethod::Squash, &settings));
316 // A value holding a placeholder is not filled in again.
317 let tricky = MessageFacts { title: "{body}", ..facts(&[], &[]) };
318 let echo = RepoSettings { merge_commit_message: CommitMessageDefault::Custom, merge_commit_template: Some("{title}".into()), ..RepoSettings::default() };
319 assert_eq!(message(MergeMethod::Merge, &echo, &tricky).0, "{body}");
320 // A template that fills in nothing falls back to the default.
321 let empty = RepoSettings { merge_commit_message: CommitMessageDefault::Custom, merge_commit_template: Some("{body}".into()), ..RepoSettings::default() };
322 let blank = MessageFacts { body: "", ..facts(&[], &[]) };
323 assert_eq!(message(MergeMethod::Merge, &empty, &blank).0, "Merge pull request #12 from ana/forks");
324 assert!(!needs_commits(MergeMethod::Merge, &echo));
325 }
326
327 #[test]
328 fn what_the_merger_writes_wins() {
329 let default = Some(("Add forks (#12)".to_owned(), "* One".to_owned()));
330 assert_eq!(chosen(default.clone(), None, None), Ok(Some("Add forks (#12)\n\n* One".into())));
331 assert_eq!(chosen(default.clone(), Some(" Mine "), None), Ok(Some("Mine\n\n* One".into())));
332 assert_eq!(chosen(default.clone(), None, Some("")), Ok(Some("Add forks (#12)".into())));
333 assert_eq!(chosen(default.clone(), Some("Mine"), Some("Why\n")), Ok(Some("Mine\n\nWhy".into())));
334 assert!(chosen(default.clone(), Some(" "), None).is_err());
335 assert!(chosen(default.clone(), Some("a\nb"), None).is_err());
336 assert!(chosen(default.clone(), Some(&"x".repeat(MAX_TITLE_CHARS + 1)), None).is_err());
337 // Nothing to write for a rebase or fast-forward.
338 assert_eq!(chosen(None, Some("Mine"), None), Ok(None));
339 }
340
341 #[test]
342 fn a_message_splits_into_title_and_body() {
343 assert_eq!(split("Title\n\n\nBody\n"), ("Title".into(), "Body".into()));
344 assert_eq!(split(" Title "), ("Title".into(), String::new()));
345 assert_eq!(compose("Title", " "), "Title");
346 }
347}