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.

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