Skip to main content

jar_genesis/
git.rs

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
14/// Run a git command and return stdout as a string.
15fn 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
28/// Run a git command (public wrapper). Returns stdout.
29pub fn git_cmd(args: &[&str]) -> Result<String, GitError> {
30    git(args)
31}
32
33/// Run a git command in a specific directory. Returns stdout.
34pub 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
47/// Get merge commits from genesis_commit..HEAD, oldest first.
48/// Returns (hash, full commit message) pairs.
49pub fn log_merge_commits(genesis_commit: &str) -> Result<Vec<(String, String)>, GitError> {
50    log_merge_commits_ref(genesis_commit, "HEAD")
51}
52
53/// Walk merge commits between `genesis_commit` and `end_ref` (e.g. "origin/master").
54pub 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
72/// Show a file from a git ref (e.g. `origin/genesis-state:genesis.json`).
73pub fn show_file(refspec: &str) -> Result<String, GitError> {
74    git(&["show", refspec])
75}
76
77/// Fetch a remote branch.
78pub fn fetch(remote: &str, branch: &str) -> Result<(), GitError> {
79    git(&["fetch", remote, branch])?;
80    Ok(())
81}
82
83/// Parse the root of the git repo.
84pub fn repo_root() -> Result<String, GitError> {
85    let root = git(&["rev-parse", "--show-toplevel"])?;
86    Ok(root.trim().to_string())
87}
88
89/// Extract a trailer value from a commit message.
90/// Trailers are lines like `Genesis-Commit: {...}` at the end of the message.
91pub 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
101/// Count the number of Genesis-Index trailers in the merge history.
102pub fn count_genesis_trailers(genesis_commit: &str) -> Result<usize, GitError> {
103    // Use origin/master to see all trailers even if HEAD is behind
104    // (e.g. during review workflow where checkout is stale).
105    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
115/// Read the genesis commit hash from the Lean source file.
116pub 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        // Match: def genesisCommit := "..."
123        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        // Real-world trailer with compact JSON
162        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        // A merge commit that has Genesis-Index but no Genesis-Commit (shouldn't happen but test it)
189        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}