chronicle/hooks/
prepare_commit_msg.rs1use std::path::Path;
2
3use crate::annotate::squash::{write_pending_squash, PendingSquash};
4use crate::error::chronicle_error::IoSnafu;
5use crate::error::Result;
6use snafu::ResultExt;
7
8pub fn handle_prepare_commit_msg(git_dir: &Path, commit_source: Option<&str>) -> Result<()> {
18 let source_commits = match detect_squash(commit_source, git_dir)? {
19 Some(commits) => commits,
20 None => return Ok(()), };
22
23 if source_commits.is_empty() {
24 tracing::debug!("Squash detected but no source commits resolved");
25 return Ok(());
26 }
27
28 let pending = PendingSquash {
29 source_commits,
30 source_ref: None,
31 timestamp: chrono::Utc::now(),
32 };
33
34 write_pending_squash(git_dir, &pending)?;
35 tracing::info!(
36 "Wrote pending-squash.json with {} source commits",
37 pending.source_commits.len()
38 );
39
40 Ok(())
41}
42
43fn detect_squash(commit_source: Option<&str>, git_dir: &Path) -> Result<Option<Vec<String>>> {
45 if commit_source == Some("squash") {
47 return resolve_squash_sources_from_squash_msg(git_dir);
48 }
49
50 let squash_msg_path = git_dir.join("SQUASH_MSG");
52 if squash_msg_path.exists() {
53 return resolve_squash_sources_from_squash_msg(git_dir);
54 }
55
56 if let Ok(sources) = std::env::var("CHRONICLE_SQUASH_SOURCES") {
58 if !sources.is_empty() {
59 return Ok(Some(parse_squash_sources_env(&sources)));
60 }
61 }
62
63 Ok(None)
64}
65
66fn resolve_squash_sources_from_squash_msg(git_dir: &Path) -> Result<Option<Vec<String>>> {
81 let squash_msg_path = git_dir.join("SQUASH_MSG");
82 if !squash_msg_path.exists() {
83 return Ok(None);
84 }
85
86 let content = std::fs::read_to_string(&squash_msg_path).context(IoSnafu)?;
87 let shas = parse_squash_msg_commits(&content);
88
89 if shas.is_empty() {
90 Ok(None)
91 } else {
92 Ok(Some(shas))
93 }
94}
95
96fn parse_squash_msg_commits(content: &str) -> Vec<String> {
98 content
99 .lines()
100 .filter_map(|line| {
101 let trimmed = line.trim();
102 if let Some(rest) = trimmed.strip_prefix("commit ") {
103 let sha = rest.split_whitespace().next()?;
105 if sha.len() >= 7 && sha.chars().all(|c| c.is_ascii_hexdigit()) {
106 Some(sha.to_string())
107 } else {
108 None
109 }
110 } else {
111 None
112 }
113 })
114 .collect()
115}
116
117fn parse_squash_sources_env(sources: &str) -> Vec<String> {
120 sources
121 .split(',')
122 .map(|s| s.trim().to_string())
123 .filter(|s| !s.is_empty())
124 .collect()
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn test_parse_squash_msg_commits() {
133 let content = r#"Squashed commit of the following:
134
135commit abc1234567890abcdef1234567890abcdef123456
136Author: Test User <test@example.com>
137Date: Mon Dec 15 10:30:00 2025 +0000
138
139 First commit message
140
141commit def4567890abcdef1234567890abcdef123456ab
142Author: Test User <test@example.com>
143Date: Mon Dec 15 10:35:00 2025 +0000
144
145 Second commit message
146"#;
147
148 let shas = parse_squash_msg_commits(content);
149 assert_eq!(shas.len(), 2);
150 assert_eq!(shas[0], "abc1234567890abcdef1234567890abcdef123456");
151 assert_eq!(shas[1], "def4567890abcdef1234567890abcdef123456ab");
152 }
153
154 #[test]
155 fn test_parse_squash_msg_no_commits() {
156 let content = "Just a regular commit message\nwith no commit lines\n";
157 let shas = parse_squash_msg_commits(content);
158 assert!(shas.is_empty());
159 }
160
161 #[test]
162 fn test_parse_squash_sources_env_comma_separated() {
163 let sources = "abc123,def456,ghi789";
164 let shas = parse_squash_sources_env(sources);
165 assert_eq!(shas, vec!["abc123", "def456", "ghi789"]);
166 }
167
168 #[test]
169 fn test_parse_squash_sources_env_with_spaces() {
170 let sources = "abc123 , def456 , ghi789";
171 let shas = parse_squash_sources_env(sources);
172 assert_eq!(shas, vec!["abc123", "def456", "ghi789"]);
173 }
174
175 #[test]
176 fn test_parse_squash_sources_env_empty() {
177 let sources = "";
178 let shas = parse_squash_sources_env(sources);
179 assert!(shas.is_empty());
180 }
181
182 #[test]
183 fn test_detect_squash_hook_arg() {
184 let dir = tempfile::tempdir().unwrap();
185 let git_dir = dir.path();
186
187 let squash_msg = "Squashed commit of the following:\n\ncommit abc1234567890abcdef1234567890abcdef123456\nAuthor: Test\nDate: now\n\n msg\n";
189 std::fs::write(git_dir.join("SQUASH_MSG"), squash_msg).unwrap();
190
191 let result = detect_squash(Some("squash"), git_dir).unwrap();
192 assert!(result.is_some());
193 assert_eq!(result.unwrap().len(), 1);
194 }
195
196 #[test]
197 fn test_detect_squash_message_arg() {
198 let dir = tempfile::tempdir().unwrap();
199 let result = detect_squash(Some("message"), dir.path()).unwrap();
200 assert!(result.is_none());
201 }
202
203 #[test]
204 fn test_detect_squash_no_signals() {
205 let dir = tempfile::tempdir().unwrap();
206 let result = detect_squash(None, dir.path()).unwrap();
207 assert!(result.is_none());
208 }
209
210 #[test]
211 fn test_detect_squash_squash_msg_file() {
212 let dir = tempfile::tempdir().unwrap();
213 let git_dir = dir.path();
214 let squash_msg =
215 "Squashed commit of the following:\n\ncommit abcdef1234567\nAuthor: Test\n\n msg\n";
216 std::fs::write(git_dir.join("SQUASH_MSG"), squash_msg).unwrap();
217
218 let result = detect_squash(None, git_dir).unwrap();
220 assert!(result.is_some());
221 }
222
223 #[test]
224 fn test_handle_prepare_commit_msg_writes_pending() {
225 let dir = tempfile::tempdir().unwrap();
226 let git_dir = dir.path();
227 let squash_msg =
228 "Squashed commit of the following:\n\ncommit abcdef1234567\nAuthor: Test\n\n msg\n";
229 std::fs::write(git_dir.join("SQUASH_MSG"), squash_msg).unwrap();
230
231 handle_prepare_commit_msg(git_dir, Some("squash")).unwrap();
232
233 let pending_path = git_dir.join("chronicle").join("pending-squash.json");
234 assert!(pending_path.exists());
235
236 let content = std::fs::read_to_string(pending_path).unwrap();
237 let pending: PendingSquash = serde_json::from_str(&content).unwrap();
238 assert_eq!(pending.source_commits.len(), 1);
239 assert_eq!(pending.source_commits[0], "abcdef1234567");
240 }
241
242 #[test]
243 fn test_handle_prepare_commit_msg_no_squash() {
244 let dir = tempfile::tempdir().unwrap();
245 let git_dir = dir.path();
246
247 handle_prepare_commit_msg(git_dir, Some("message")).unwrap();
248
249 let pending_path = git_dir.join("chronicle").join("pending-squash.json");
250 assert!(!pending_path.exists());
251 }
252}