Skip to main content

standard_commit/
process.rs

1/// Returns `true` if the commit message looks like an automatically generated
2/// process commit — merge, revert, fixup, squash, or initial commit.
3///
4/// Detection is based on the first line (subject) of the message only, using
5/// well-known prefix patterns from GitHub, GitLab, Gerrit, and git itself.
6/// No git parent-count check is performed; this works across all input modes.
7///
8/// # Process commit patterns
9///
10/// | Pattern                          | Source             |
11/// | -------------------------------- | ------------------ |
12/// | `Merge pull request #N …`        | GitHub             |
13/// | `Merge branch '…'`               | GitHub/GitLab/git  |
14/// | `Merge tag '…'`                  | git                |
15/// | `Merge remote-tracking branch …` | git                |
16/// | `Merge "…"`                      | Gerrit             |
17/// | `Merge changes …`                | Gerrit             |
18/// | `Revert "…"`                     | `git revert`       |
19/// | `fixup! …`                       | `git commit --fixup`   |
20/// | `squash! …`                      | `git commit --squash`  |
21/// | `Initial commit`                 | GitHub / convention |
22///
23/// # Examples
24///
25/// ```
26/// use standard_commit::is_process_commit;
27///
28/// assert!(is_process_commit("Merge pull request #42 from owner/branch"));
29/// assert!(is_process_commit("Revert \"feat: add login\""));
30/// assert!(is_process_commit("fixup! fix: handle timeout"));
31/// assert!(!is_process_commit("feat: add login"));
32/// assert!(!is_process_commit("chore: update deps"));
33/// ```
34pub fn is_process_commit(message: &str) -> bool {
35    let subject = message.lines().next().unwrap_or("").trim();
36    is_process_subject(subject)
37}
38
39fn is_process_subject(subject: &str) -> bool {
40    // Merge patterns (case-sensitive — git always capitalises "Merge")
41    if subject.starts_with("Merge pull request #") {
42        return true;
43    }
44    if subject.starts_with("Merge branch '") {
45        return true;
46    }
47    if subject.starts_with("Merge tag '") {
48        return true;
49    }
50    if subject.starts_with("Merge remote-tracking branch ") {
51        return true;
52    }
53    // Gerrit: `Merge "..."` and `Merge changes ...`
54    if subject.starts_with("Merge \"") {
55        return true;
56    }
57    if subject.starts_with("Merge changes ") {
58        return true;
59    }
60    // git revert: `Revert "..."`
61    if subject.starts_with("Revert \"") {
62        return true;
63    }
64    // git commit --fixup / --squash
65    if subject.starts_with("fixup! ") {
66        return true;
67    }
68    if subject.starts_with("squash! ") {
69        return true;
70    }
71    // Initial commit (GitHub new-repo default and common convention)
72    if subject == "Initial commit" {
73        return true;
74    }
75
76    false
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    // --- Merge patterns ---
84
85    #[test]
86    fn github_merge_pull_request() {
87        assert!(is_process_commit(
88            "Merge pull request #42 from owner/feature-branch"
89        ));
90    }
91
92    #[test]
93    fn merge_branch() {
94        assert!(is_process_commit("Merge branch 'main' into feature"));
95        assert!(is_process_commit("Merge branch 'develop'"));
96    }
97
98    #[test]
99    fn merge_tag() {
100        assert!(is_process_commit("Merge tag 'v1.2.3'"));
101    }
102
103    #[test]
104    fn merge_remote_tracking_branch() {
105        assert!(is_process_commit(
106            "Merge remote-tracking branch 'origin/main'"
107        ));
108    }
109
110    #[test]
111    fn gerrit_merge_quoted() {
112        assert!(is_process_commit("Merge \"feat: add login\""));
113    }
114
115    #[test]
116    fn gerrit_merge_changes() {
117        assert!(is_process_commit("Merge changes Iabc1234,Idef5678"));
118    }
119
120    // --- Revert ---
121
122    #[test]
123    fn git_revert() {
124        assert!(is_process_commit("Revert \"feat: add login\""));
125        assert!(is_process_commit("Revert \"fix(auth): handle timeout\""));
126    }
127
128    // --- Fixup / squash ---
129
130    #[test]
131    fn fixup_commit() {
132        assert!(is_process_commit("fixup! fix: handle timeout"));
133        assert!(is_process_commit("fixup! feat(auth): add OAuth2 PKCE"));
134    }
135
136    #[test]
137    fn squash_commit() {
138        assert!(is_process_commit("squash! feat: add login"));
139    }
140
141    // --- Initial commit ---
142
143    #[test]
144    fn initial_commit() {
145        assert!(is_process_commit("Initial commit"));
146    }
147
148    // --- Conventional commits are NOT process commits ---
149
150    #[test]
151    fn conventional_feat_is_not_process() {
152        assert!(!is_process_commit("feat: add login"));
153    }
154
155    #[test]
156    fn conventional_fix_is_not_process() {
157        assert!(!is_process_commit("fix(auth): handle expired tokens"));
158    }
159
160    #[test]
161    fn revert_type_conventional_is_not_process() {
162        // `revert(scope): ...` follows CC format and is NOT a process commit
163        assert!(!is_process_commit("revert(auth): undo broken migration"));
164    }
165
166    #[test]
167    fn bare_merge_word_is_not_process() {
168        // Loose "Merge" without a recognised pattern should not match
169        assert!(!is_process_commit("Merge: resolve conflict"));
170    }
171
172    #[test]
173    fn lowercase_initial_commit_is_not_process() {
174        assert!(!is_process_commit("initial commit"));
175    }
176
177    #[test]
178    fn multiline_message_uses_subject_only() {
179        let msg = "feat: add login\n\nMerge pull request #1 from owner/branch";
180        assert!(!is_process_commit(msg));
181    }
182}