1use std::sync::OnceLock;
11
12use regex::Regex;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum GitEvent {
17 Commit,
18 Push,
19}
20
21pub struct Trigger;
24
25fn pattern() -> &'static Regex {
26 static RE: OnceLock<Regex> = OnceLock::new();
27 RE.get_or_init(|| {
28 Regex::new(r"(?:^|[\s;|&()])git\s+(commit|push)(?:\s|$)")
31 .expect("trigger regex must compile")
32 })
33}
34
35impl Trigger {
36 pub fn classify(cmd: &str) -> Option<GitEvent> {
40 let captures = pattern().captures(cmd)?;
41 match captures.get(1)?.as_str() {
42 "commit" => Some(GitEvent::Commit),
43 "push" => Some(GitEvent::Push),
44 _ => None,
45 }
46 }
47}
48
49#[cfg(test)]
50mod tests {
51 use super::*;
52
53 #[test]
54 fn matches_bare_commit() {
55 assert_eq!(Trigger::classify("git commit"), Some(GitEvent::Commit));
56 }
57
58 #[test]
59 fn matches_commit_with_flags() {
60 assert_eq!(
61 Trigger::classify("git commit -m 'wip'"),
62 Some(GitEvent::Commit),
63 );
64 }
65
66 #[test]
67 fn matches_bare_push() {
68 assert_eq!(Trigger::classify("git push"), Some(GitEvent::Push));
69 }
70
71 #[test]
72 fn matches_push_with_flags() {
73 assert_eq!(
74 Trigger::classify("git push origin main"),
75 Some(GitEvent::Push),
76 );
77 }
78
79 #[test]
80 fn matches_chained_with_double_amp() {
81 assert_eq!(
82 Trigger::classify("cargo test && git push"),
83 Some(GitEvent::Push),
84 );
85 }
86
87 #[test]
88 fn matches_chained_with_semicolon() {
89 assert_eq!(
90 Trigger::classify("cargo fmt; git commit"),
91 Some(GitEvent::Commit),
92 );
93 }
94
95 #[test]
96 fn matches_chained_with_pipe() {
97 assert_eq!(
98 Trigger::classify("echo hi | git commit -F -"),
99 Some(GitEvent::Commit),
100 );
101 }
102
103 #[test]
104 fn matches_subshell_parens() {
105 assert_eq!(
109 Trigger::classify("(git commit -m 'wip')"),
110 Some(GitEvent::Commit),
111 );
112 }
113
114 #[test]
115 fn matches_subshell_push() {
116 assert_eq!(
117 Trigger::classify("(cd subdir && git push origin main)"),
118 Some(GitEvent::Push),
119 );
120 }
121
122 #[test]
123 fn rejects_forgit() {
124 assert_eq!(Trigger::classify("forgit commit"), None);
125 }
126
127 #[test]
128 fn rejects_mygit() {
129 assert_eq!(Trigger::classify("mygit push"), None);
130 }
131
132 #[test]
133 fn rejects_committed_substring() {
134 assert_eq!(Trigger::classify("git committed-files-tool"), None);
137 assert_eq!(Trigger::classify("git committed"), None);
140 }
141
142 #[test]
143 fn rejects_unrelated_git_subcommand() {
144 assert_eq!(Trigger::classify("git status"), None);
145 assert_eq!(Trigger::classify("git log"), None);
146 }
147
148 #[test]
149 fn rejects_plain_text() {
150 assert_eq!(Trigger::classify("ls -la"), None);
151 assert_eq!(Trigger::classify(""), None);
152 }
153
154 #[test]
160 #[ignore = "design §6 known limitation; tracked for v0.2"]
161 fn matches_git_dash_c_commit() {
162 assert_eq!(
163 Trigger::classify("git -c user.email=x@y.z commit"),
164 Some(GitEvent::Commit),
165 );
166 }
167
168 #[test]
173 #[ignore = "design §6 deliberate non-goal; klasp gates honest agents"]
174 fn deliberately_misses_bash_c_quoted() {
175 assert_eq!(
176 Trigger::classify(r#"bash -c "git push""#),
177 Some(GitEvent::Push),
178 );
179 }
180
181 #[test]
184 #[ignore = "design §6 deliberate non-goal; klasp gates honest agents"]
185 fn deliberately_misses_eval_quoted() {
186 assert_eq!(
187 Trigger::classify(r#"eval "git commit""#),
188 Some(GitEvent::Commit),
189 );
190 }
191
192 #[test]
196 #[ignore = "design §6 deliberate non-goal; v0.2 candidate"]
197 fn deliberately_misses_env_prefixed() {
198 assert_eq!(
199 Trigger::classify("GIT_DIR=/elsewhere git push"),
200 Some(GitEvent::Push),
201 );
202 }
203
204 #[test]
208 #[ignore = "design §6 deliberate non-goal; shell aliases are out of scope"]
209 fn deliberately_misses_alias() {
210 assert_eq!(Trigger::classify("gp"), Some(GitEvent::Push));
211 }
212}