Skip to main content

chronicle/hooks/
prepare_commit_msg.rs

1use 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
8/// Handle the prepare-commit-msg hook.
9///
10/// Detects squash operations and writes pending-squash.json so that
11/// the post-commit hook can route to squash synthesis.
12///
13/// Detection signals (any one sufficient):
14/// 1. `commit_source` argument is "squash"
15/// 2. `.git/SQUASH_MSG` file exists
16/// 3. `CHRONICLE_SQUASH_SOURCES` environment variable is set
17pub 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(()), // Not a squash
21    };
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
43/// Detect whether this commit is a squash operation and resolve source commit SHAs.
44fn detect_squash(commit_source: Option<&str>, git_dir: &Path) -> Result<Option<Vec<String>>> {
45    // Check 1: hook argument
46    if commit_source == Some("squash") {
47        return resolve_squash_sources_from_squash_msg(git_dir);
48    }
49
50    // Check 2: SQUASH_MSG file existence
51    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    // Check 3: environment variable
57    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
66/// Parse source commit SHAs from the SQUASH_MSG file.
67///
68/// During `git merge --squash`, SQUASH_MSG contains lines like:
69/// ```text
70/// Squashed commit of the following:
71///
72/// commit abc1234...
73/// Author: ...
74/// Date: ...
75///     First commit message
76///
77/// commit def5678...
78/// ...
79/// ```
80fn 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
96/// Parse commit SHAs from SQUASH_MSG content.
97fn 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                // Take the first word (the SHA), ignoring any trailing info
104                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
117/// Parse source commits from the CHRONICLE_SQUASH_SOURCES env var.
118/// Supports comma-separated SHA list.
119fn 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        // Create a SQUASH_MSG file so the resolution path works
188        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        // No hook argument, but SQUASH_MSG exists
219        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}