1use std::path::Path;
2use std::process::Command;
3
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum GitError {
8 #[error("git command failed: {0}")]
9 CommandFailed(String),
10 #[error("io error: {0}")]
11 Io(#[from] std::io::Error),
12}
13
14fn git(args: &[&str]) -> Result<String, GitError> {
16 let output = Command::new("git").args(args).output()?;
17 if !output.status.success() {
18 let stderr = String::from_utf8_lossy(&output.stderr);
19 return Err(GitError::CommandFailed(format!(
20 "git {} failed: {}",
21 args.join(" "),
22 stderr.trim()
23 )));
24 }
25 Ok(String::from_utf8_lossy(&output.stdout).to_string())
26}
27
28pub fn git_cmd(args: &[&str]) -> Result<String, GitError> {
30 git(args)
31}
32
33pub fn git_cmd_in(dir: &str, args: &[&str]) -> Result<String, GitError> {
35 let output = Command::new("git").arg("-C").arg(dir).args(args).output()?;
36 if !output.status.success() {
37 let stderr = String::from_utf8_lossy(&output.stderr);
38 return Err(GitError::CommandFailed(format!(
39 "git -C {dir} {} failed: {}",
40 args.join(" "),
41 stderr.trim()
42 )));
43 }
44 Ok(String::from_utf8_lossy(&output.stdout).to_string())
45}
46
47pub fn log_merge_commits(genesis_commit: &str) -> Result<Vec<(String, String)>, GitError> {
50 log_merge_commits_ref(genesis_commit, "HEAD")
51}
52
53pub fn log_merge_commits_ref(
55 genesis_commit: &str,
56 end_ref: &str,
57) -> Result<Vec<(String, String)>, GitError> {
58 let range = format!("{genesis_commit}..{end_ref}");
59 let hashes = git(&["log", "--merges", "--reverse", "--format=%H", &range])?;
60 let mut result = Vec::new();
61 for hash in hashes.lines() {
62 let hash = hash.trim();
63 if hash.is_empty() {
64 continue;
65 }
66 let message = git(&["log", "-1", "--format=%B", hash])?;
67 result.push((hash.to_string(), message));
68 }
69 Ok(result)
70}
71
72pub fn show_file(refspec: &str) -> Result<String, GitError> {
74 git(&["show", refspec])
75}
76
77pub fn fetch(remote: &str, branch: &str) -> Result<(), GitError> {
79 git(&["fetch", remote, branch])?;
80 Ok(())
81}
82
83pub fn repo_root() -> Result<String, GitError> {
85 let root = git(&["rev-parse", "--show-toplevel"])?;
86 Ok(root.trim().to_string())
87}
88
89pub fn parse_trailer(message: &str, key: &str) -> Option<String> {
92 let prefix = format!("{key}: ");
93 for line in message.lines() {
94 if let Some(rest) = line.strip_prefix(&prefix) {
95 return Some(rest.to_string());
96 }
97 }
98 None
99}
100
101pub fn count_genesis_trailers(genesis_commit: &str) -> Result<usize, GitError> {
103 let _ = git(&["fetch", "origin", "master"]);
106 let range = format!("{genesis_commit}..origin/master");
107 let output = git(&["log", "--merges", "--format=%B", &range])?;
108 let count = output
109 .lines()
110 .filter(|line| line.starts_with("Genesis-Index: "))
111 .count();
112 Ok(count)
113}
114
115pub fn read_genesis_commit_hash(spec_dir: &Path) -> Result<String, GitError> {
117 let state_file = spec_dir.join("Genesis/State.lean");
118 let content = std::fs::read_to_string(&state_file).map_err(|e| {
119 GitError::CommandFailed(format!("failed to read {}: {e}", state_file.display()))
120 })?;
121 for line in content.lines() {
122 if let Some(rest) = line.strip_prefix("def genesisCommit")
124 && let Some(start) = rest.find('"')
125 && let Some(end) = rest[start + 1..].find('"')
126 {
127 return Ok(rest[start + 1..start + 1 + end].to_string());
128 }
129 }
130 Err(GitError::CommandFailed(
131 "genesisCommit not found in Genesis/State.lean".to_string(),
132 ))
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn test_parse_trailer() {
141 let msg = "Merge PR #97\n\nGenesis-Commit: {\"id\":\"abc\"}\nGenesis-Index: {\"commitHash\":\"abc\"}\n";
142 assert_eq!(
143 parse_trailer(msg, "Genesis-Commit"),
144 Some("{\"id\":\"abc\"}".to_string())
145 );
146 assert_eq!(
147 parse_trailer(msg, "Genesis-Index"),
148 Some("{\"commitHash\":\"abc\"}".to_string())
149 );
150 assert_eq!(parse_trailer(msg, "Missing-Key"), None);
151 }
152
153 #[test]
154 fn test_parse_trailer_no_trailers() {
155 let msg = "Just a plain commit message\n";
156 assert_eq!(parse_trailer(msg, "Genesis-Commit"), None);
157 }
158
159 #[test]
160 fn test_parse_trailer_compact_json() {
161 let msg = r#"Merge PR #97
163
164Genesis-Commit: {"id":"abc","prId":97,"author":"alice","mergeEpoch":1000,"comparisonTargets":[],"reviews":[],"metaReviews":[],"founderOverride":false}
165Genesis-Index: {"commitHash":"abc","epoch":1000,"score":{"difficulty":85,"novelty":100,"designQuality":85},"contributor":"alice","weightDelta":88,"reviewers":[],"metaReviews":[],"mergeVotes":[],"rejectVotes":[],"founderOverride":false}
166Genesis-PR: #97
167Genesis-Author: alice
168"#;
169 let commit = parse_trailer(msg, "Genesis-Commit").unwrap();
170 let parsed: serde_json::Value = serde_json::from_str(&commit).unwrap();
171 assert_eq!(parsed["id"], "abc");
172 assert_eq!(parsed["prId"], 97);
173
174 let index = parse_trailer(msg, "Genesis-Index").unwrap();
175 let parsed: serde_json::Value = serde_json::from_str(&index).unwrap();
176 assert_eq!(parsed["commitHash"], "abc");
177 assert_eq!(parsed["score"]["difficulty"], 85);
178 }
179
180 #[test]
181 fn test_parse_trailer_pr_number() {
182 let msg = "Merge PR #42\n\nGenesis-PR: #42\n";
183 assert_eq!(parse_trailer(msg, "Genesis-PR"), Some("#42".to_string()));
184 }
185
186 #[test]
187 fn test_parse_trailer_only_index_no_commit() {
188 let msg = "Merge PR #1\n\nGenesis-Index: {\"commitHash\":\"abc\"}\n";
190 assert!(parse_trailer(msg, "Genesis-Index").is_some());
191 assert!(parse_trailer(msg, "Genesis-Commit").is_none());
192 }
193}