Skip to main content

perfgate_app/
bisect.rs

1//! Bisection orchestration.
2
3use anyhow::Context;
4use perfgate_adapters::{CommandSpec, ProcessRunner, StdProcessRunner};
5use std::fs;
6use std::path::PathBuf;
7use std::process::Command;
8
9pub struct BisectRequest {
10    pub good: String,
11    pub bad: String,
12    pub build_cmd: String,
13    pub executable: PathBuf,
14    pub threshold: f64,
15}
16
17pub struct BisectUseCase<R: ProcessRunner> {
18    runner: R,
19}
20
21impl Default for BisectUseCase<StdProcessRunner> {
22    fn default() -> Self {
23        Self::new(StdProcessRunner)
24    }
25}
26
27impl<R: ProcessRunner> BisectUseCase<R> {
28    pub fn new(runner: R) -> Self {
29        Self { runner }
30    }
31
32    pub fn execute(&self, req: BisectRequest) -> anyhow::Result<()> {
33        let original_branch = Self::get_current_branch()?;
34
35        // 1. Checkout good commit
36        println!("Checking out good commit: {}", req.good);
37        Self::run_git(&["checkout", &req.good])?;
38
39        // 2. Build good commit
40        println!("Building baseline...");
41        self.run_shell(&req.build_cmd)?;
42
43        // 3. Copy executable to temp
44        let baseline_exe = req.executable.with_extension("baseline.exe");
45        fs::copy(&req.executable, &baseline_exe).context("Failed to copy baseline executable")?;
46
47        // 4. Start bisection
48        println!("Starting git bisect...");
49        Self::run_git(&["bisect", "start", &req.bad, &req.good])?;
50
51        // 5. Loop until bisect finishes
52        loop {
53            println!("\nBuilding current commit...");
54            let build_res = self.run_shell(&req.build_cmd);
55
56            let result = if build_res.is_err() || build_res.unwrap().exit_code != 0 {
57                println!("Build failed, skipping commit...");
58                "skip"
59            } else {
60                println!("Running performance comparison...");
61                let current_exe = std::env::current_exe()?;
62                let mut paired = Command::new(current_exe);
63                paired.args([
64                    "paired",
65                    "--name",
66                    "bisect",
67                    "--baseline-cmd",
68                    &baseline_exe.to_string_lossy(),
69                    "--current-cmd",
70                    &req.executable.to_string_lossy(),
71                    "--fail-on-regression",
72                    &req.threshold.to_string(),
73                    "--require-significance",
74                ]);
75
76                let paired_status = paired.status().context("Failed to run perfgate paired")?;
77
78                if paired_status.success() {
79                    println!("Performance looks good!");
80                    "good"
81                } else {
82                    println!("Performance regressed!");
83                    "bad"
84                }
85            };
86
87            let out = Command::new("git")
88                .args(["bisect", result])
89                .output()
90                .context("Failed to run git bisect step")?;
91            let stdout = String::from_utf8_lossy(&out.stdout);
92
93            if stdout.contains("is the first bad commit") {
94                println!("\n{}", stdout);
95
96                // Regression Blame
97                if let Some(first_word) = stdout.split_whitespace().next() {
98                    let author_out = Command::new("git")
99                        .args(["show", "-s", "--format=%an <%ae>", first_word])
100                        .output()
101                        .ok();
102                    if let Some(author_out) = author_out
103                        && author_out.status.success()
104                    {
105                        let author = String::from_utf8_lossy(&author_out.stdout)
106                            .trim()
107                            .to_string();
108                        println!("Regression Blame: Likely introduced by {}", author);
109                    }
110                }
111
112                break;
113            } else if !out.status.success() {
114                anyhow::bail!(
115                    "git bisect failed: {}",
116                    String::from_utf8_lossy(&out.stderr)
117                );
118            }
119        }
120
121        // Cleanup
122        println!("Cleaning up...");
123        let _ = Self::run_git(&["bisect", "reset"]);
124        if !original_branch.is_empty() {
125            let _ = Self::run_git(&["checkout", &original_branch]);
126        }
127        let _ = fs::remove_file(&baseline_exe);
128
129        Ok(())
130    }
131
132    fn get_current_branch() -> anyhow::Result<String> {
133        let out = Command::new("git")
134            .args(["rev-parse", "--abbrev-ref", "HEAD"])
135            .output()
136            .context("Failed to get current branch")?;
137        Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
138    }
139
140    fn run_git(args: &[&str]) -> anyhow::Result<()> {
141        let status = Command::new("git").args(args).status()?;
142        if !status.success() {
143            anyhow::bail!("git command failed: {:?}", args);
144        }
145        Ok(())
146    }
147
148    fn run_shell(&self, cmd: &str) -> anyhow::Result<perfgate_adapters::RunResult> {
149        let spec = if cfg!(windows) {
150            CommandSpec {
151                name: "cmd".to_string(),
152                argv: vec!["/C".to_string(), cmd.to_string()],
153                ..Default::default()
154            }
155        } else {
156            CommandSpec {
157                name: "sh".to_string(),
158                argv: vec!["-c".to_string(), cmd.to_string()],
159                ..Default::default()
160            }
161        };
162
163        self.runner.run(&spec).map_err(|e| anyhow::anyhow!(e))
164    }
165}