Skip to main content

bcore_mutation/
git_changes.rs

1use crate::error::{MutationError, Result};
2use crate::project::Project;
3use regex::Regex;
4use std::process::Command;
5use std::str;
6
7/// Local branch name where the secp256k1 `master` is fetched, used as the diff base.
8const SECP256K1_BASE_REF: &str = "secp256k1-master";
9
10pub async fn run_git_command(args: &[&str]) -> Result<Vec<String>> {
11    let output = Command::new("git")
12        .args(args)
13        .output()
14        .map_err(|e| MutationError::Git(format!("Failed to execute git command: {}", e)))?;
15
16    if !output.status.success() {
17        let stderr = str::from_utf8(&output.stderr).unwrap_or("Unknown error");
18        return Err(MutationError::Git(format!(
19            "Git command failed: {}",
20            stderr
21        )));
22    }
23
24    let stdout = str::from_utf8(&output.stdout)
25        .map_err(|e| MutationError::Git(format!("Invalid UTF-8 in git output: {}", e)))?;
26
27    Ok(stdout.lines().map(|s| s.to_string()).collect())
28}
29
30pub async fn get_commit_hash() -> Result<String> {
31    let lines = run_git_command(&["rev-parse", "HEAD"]).await?;
32    Ok(lines.into_iter().next().unwrap_or_default())
33}
34
35pub async fn get_changed_files(pr_number: Option<u32>, project: Project) -> Result<Vec<String>> {
36    match project {
37        Project::BitcoinCore => get_changed_files_bitcoin_core(pr_number).await,
38        Project::Secp256k1 => get_changed_files_secp256k1(pr_number).await,
39    }
40}
41
42/// Fetch a secp256k1 PR directly from its GitHub URL and return the changed files.
43///
44/// Unlike Bitcoin Core (which relies on a configured `upstream`/`origin` remote),
45/// secp256k1 PRs are fetched straight from the repository URL. `master` is also
46/// fetched into a local ref so we have a base to diff against.
47async fn get_changed_files_secp256k1(pr_number: Option<u32>) -> Result<Vec<String>> {
48    let url = Project::Secp256k1.repository_url();
49
50    // Fetch master into a local ref to diff against (force-update to stay current).
51    let fetch_master_args = &["fetch", url, &format!("+master:{}", SECP256K1_BASE_REF)];
52    run_git_command(fetch_master_args).await?;
53
54    if let Some(pr) = pr_number {
55        println!("Fetching secp256k1 PR #{} from {}", pr, url);
56        let fetch_pr_args = &["fetch", url, &format!("pull/{}/head:pr/{}", pr, pr)];
57        run_git_command(fetch_pr_args).await?;
58        println!("Checking out pr/{}...", pr);
59        run_git_command(&["checkout", &format!("pr/{}", pr)]).await?;
60    }
61
62    let diff_args = &[
63        "diff",
64        "--name-only",
65        "--diff-filter=d",
66        &format!("{}...HEAD", SECP256K1_BASE_REF),
67    ];
68    run_git_command(diff_args).await
69}
70
71async fn get_changed_files_bitcoin_core(pr_number: Option<u32>) -> Result<Vec<String>> {
72    let mut used_remote = "upstream"; // Track which remote we successfully used
73
74    if let Some(pr) = pr_number {
75        // Try to fetch the PR from upstream first
76        let fetch_upstream_args = &["fetch", "upstream", &format!("pull/{}/head:pr/{}", pr, pr)];
77        match run_git_command(fetch_upstream_args).await {
78            Ok(_) => {
79                println!("Successfully fetched from upstream");
80                println!("Checking out...");
81                let checkout_args = &["checkout", &format!("pr/{}", pr)];
82                run_git_command(checkout_args).await?;
83            }
84            Err(upstream_err) => {
85                println!("Failed to fetch from upstream: {:?}", upstream_err);
86                println!("Trying to fetch from origin...");
87
88                // Try to fetch from origin as fallback
89                let fetch_origin_args =
90                    &["fetch", "origin", &format!("pull/{}/head:pr/{}", pr, pr)];
91                match run_git_command(fetch_origin_args).await {
92                    Ok(_) => {
93                        println!("Successfully fetched from origin");
94                        used_remote = "origin";
95                        println!("Checking out...");
96                        let checkout_args = &["checkout", &format!("pr/{}", pr)];
97                        run_git_command(checkout_args).await?;
98                    }
99                    Err(origin_err) => {
100                        println!("Failed to fetch from origin: {:?}", origin_err);
101                        println!("Attempting to rebase existing pr/{} branch...", pr);
102                        let rebase_args = &["rebase", &format!("pr/{}", pr)];
103                        run_git_command(rebase_args).await?;
104                        // In rebase case, we don't know which remote was used originally
105                        // Try upstream first, fall back to origin if it fails
106                    }
107                }
108            }
109        }
110    }
111
112    // Try diff with the appropriate remote
113    let diff_args = &[
114        "diff",
115        "--name-only",
116        "--diff-filter=d",
117        &format!("{}/master...HEAD", used_remote),
118    ];
119    match run_git_command(diff_args).await {
120        Ok(result) => Ok(result),
121        Err(_) if used_remote == "upstream" => {
122            // If upstream diff failed, try origin
123            println!("Diff with upstream/master failed, trying origin/master...");
124            let diff_args_origin = &["diff", "--name-only", "origin/master...HEAD"];
125            run_git_command(diff_args_origin).await
126        }
127        Err(e) => Err(e),
128    }
129}
130
131pub async fn get_lines_touched(file_path: &str, project: Project) -> Result<Vec<usize>> {
132    let diff_output = match project {
133        Project::Secp256k1 => {
134            // master was fetched into a local ref by get_changed_files_secp256k1.
135            let diff_args = &[
136                "diff",
137                "--unified=0",
138                &format!("{}...HEAD", SECP256K1_BASE_REF),
139                "--",
140                file_path,
141            ];
142            run_git_command(diff_args).await?
143        }
144        Project::BitcoinCore => {
145            // Try upstream first
146            let diff_args_upstream = &[
147                "diff",
148                "--unified=0",
149                "upstream/master...HEAD",
150                "--",
151                file_path,
152            ];
153
154            match run_git_command(diff_args_upstream).await {
155                Ok(output) => output,
156                Err(_) => {
157                    // Fall back to origin if upstream fails
158                    println!("Diff with upstream/master failed, trying origin/master...");
159                    let diff_args_origin = &[
160                        "diff",
161                        "--unified=0",
162                        "origin/master...HEAD",
163                        "--",
164                        file_path,
165                    ];
166                    run_git_command(diff_args_origin).await?
167                }
168            }
169        }
170    };
171
172    let mut lines = Vec::new();
173    let line_range_regex = Regex::new(r"@@.*\+(\d+)(?:,(\d+))?.*@@")?;
174    for line in diff_output {
175        if line.starts_with("@@") {
176            if let Some(captures) = line_range_regex.captures(&line) {
177                let start_line: usize = captures[1]
178                    .parse()
179                    .map_err(|_| MutationError::Git("Invalid line number in diff".to_string()))?;
180                let num_lines = if let Some(count_match) = captures.get(2) {
181                    count_match
182                        .as_str()
183                        .parse::<usize>()
184                        .map_err(|_| MutationError::Git("Invalid line count in diff".to_string()))?
185                } else {
186                    1
187                };
188                lines.extend(start_line..start_line + num_lines);
189            }
190        }
191    }
192    Ok(lines)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[tokio::test]
200    async fn test_get_lines_touched_parsing() {
201        // This would require a git repository setup, so we'll test the regex parsing logic
202        let line_range_regex = Regex::new(r"@@.*\+(\d+)(?:,(\d+))?.*@@").unwrap();
203
204        // Test single line change
205        let single_line = "@@ -10,0 +11 @@ some context";
206        if let Some(captures) = line_range_regex.captures(single_line) {
207            let start_line: usize = captures[1].parse().unwrap();
208            let num_lines = if let Some(count_match) = captures.get(2) {
209                count_match.as_str().parse::<usize>().unwrap()
210            } else {
211                1
212            };
213            assert_eq!(start_line, 11);
214            assert_eq!(num_lines, 1);
215        }
216
217        // Test multiple line change
218        let multi_line = "@@ -10,3 +11,5 @@ some context";
219        if let Some(captures) = line_range_regex.captures(multi_line) {
220            let start_line: usize = captures[1].parse().unwrap();
221            let num_lines = captures.get(2).unwrap().as_str().parse::<usize>().unwrap();
222            assert_eq!(start_line, 11);
223            assert_eq!(num_lines, 5);
224        }
225    }
226}