reth_bench_compare/
git.rs

1//! Git operations for branch management.
2
3use eyre::{eyre, Result, WrapErr};
4use std::process::Command;
5use tracing::{info, warn};
6
7/// Manages git operations for branch switching
8#[derive(Debug, Clone)]
9pub(crate) struct GitManager {
10    repo_root: String,
11}
12
13impl GitManager {
14    /// Create a new `GitManager`, detecting the repository root
15    pub(crate) fn new() -> Result<Self> {
16        let output = Command::new("git")
17            .args(["rev-parse", "--show-toplevel"])
18            .output()
19            .wrap_err("Failed to execute git command - is git installed?")?;
20
21        if !output.status.success() {
22            return Err(eyre!("Not in a git repository or git command failed"));
23        }
24
25        let repo_root = String::from_utf8(output.stdout)
26            .wrap_err("Git output is not valid UTF-8")?
27            .trim()
28            .to_string();
29
30        let manager = Self { repo_root };
31        info!(
32            "Detected git repository at: {}, current reference: {}",
33            manager.repo_root(),
34            manager.get_current_ref()?
35        );
36
37        Ok(manager)
38    }
39
40    /// Get the current git branch name
41    pub(crate) fn get_current_branch(&self) -> Result<String> {
42        let output = Command::new("git")
43            .args(["branch", "--show-current"])
44            .current_dir(&self.repo_root)
45            .output()
46            .wrap_err("Failed to get current branch")?;
47
48        if !output.status.success() {
49            return Err(eyre!("Failed to determine current branch"));
50        }
51
52        let branch = String::from_utf8(output.stdout)
53            .wrap_err("Branch name is not valid UTF-8")?
54            .trim()
55            .to_string();
56
57        if branch.is_empty() {
58            return Err(eyre!("Not on a named branch (detached HEAD?)"));
59        }
60
61        Ok(branch)
62    }
63
64    /// Get the current git reference (branch name, tag, or commit hash)
65    pub(crate) fn get_current_ref(&self) -> Result<String> {
66        // First try to get branch name
67        if let Ok(branch) = self.get_current_branch() {
68            return Ok(branch);
69        }
70
71        // If not on a branch, check if we're on a tag
72        let tag_output = Command::new("git")
73            .args(["describe", "--exact-match", "--tags", "HEAD"])
74            .current_dir(&self.repo_root)
75            .output()
76            .wrap_err("Failed to check for tag")?;
77
78        if tag_output.status.success() {
79            let tag = String::from_utf8(tag_output.stdout)
80                .wrap_err("Tag name is not valid UTF-8")?
81                .trim()
82                .to_string();
83            return Ok(tag);
84        }
85
86        // If not on a branch or tag, return the commit hash
87        let commit_output = Command::new("git")
88            .args(["rev-parse", "HEAD"])
89            .current_dir(&self.repo_root)
90            .output()
91            .wrap_err("Failed to get current commit")?;
92
93        if !commit_output.status.success() {
94            return Err(eyre!("Failed to get current commit hash"));
95        }
96
97        let commit_hash = String::from_utf8(commit_output.stdout)
98            .wrap_err("Commit hash is not valid UTF-8")?
99            .trim()
100            .to_string();
101
102        Ok(commit_hash)
103    }
104
105    /// Check if the git working directory has uncommitted changes to tracked files
106    pub(crate) fn validate_clean_state(&self) -> Result<()> {
107        let output = Command::new("git")
108            .args(["status", "--porcelain"])
109            .current_dir(&self.repo_root)
110            .output()
111            .wrap_err("Failed to check git status")?;
112
113        if !output.status.success() {
114            return Err(eyre!("Git status command failed"));
115        }
116
117        let status_output =
118            String::from_utf8(output.stdout).wrap_err("Git status output is not valid UTF-8")?;
119
120        // Check for uncommitted changes to tracked files
121        // Status codes: M = modified, A = added, D = deleted, R = renamed, C = copied, U = updated
122        // ?? = untracked files (we want to ignore these)
123        let has_uncommitted_changes = status_output.lines().any(|line| {
124            if line.len() >= 2 {
125                let status = &line[0..2];
126                // Ignore untracked files (??) and ignored files (!!)
127                !matches!(status, "??" | "!!")
128            } else {
129                false
130            }
131        });
132
133        if has_uncommitted_changes {
134            warn!("Git working directory has uncommitted changes to tracked files:");
135            for line in status_output.lines() {
136                if line.len() >= 2 && !matches!(&line[0..2], "??" | "!!") {
137                    warn!("  {}", line);
138                }
139            }
140            return Err(eyre!(
141                "Git working directory has uncommitted changes to tracked files. Please commit or stash changes before running benchmark comparison."
142            ));
143        }
144
145        // Check if there are untracked files and log them as info
146        let untracked_files: Vec<&str> =
147            status_output.lines().filter(|line| line.starts_with("??")).collect();
148
149        if !untracked_files.is_empty() {
150            info!(
151                "Git working directory has {} untracked files (this is OK)",
152                untracked_files.len()
153            );
154        }
155
156        info!("Git working directory is clean (no uncommitted changes to tracked files)");
157        Ok(())
158    }
159
160    /// Fetch all refs from remote to ensure we have latest branches and tags
161    pub(crate) fn fetch_all(&self) -> Result<()> {
162        let output = Command::new("git")
163            .args(["fetch", "--all", "--tags", "--quiet", "--force"])
164            .current_dir(&self.repo_root)
165            .output()
166            .wrap_err("Failed to fetch latest refs")?;
167
168        if output.status.success() {
169            info!("Fetched latest refs");
170        } else {
171            let stderr = String::from_utf8_lossy(&output.stderr);
172            // Only warn if there's actual error content, not just fetch progress
173            if !stderr.trim().is_empty() && !stderr.contains("-> origin/") {
174                warn!("Git fetch encountered issues (continuing anyway): {}", stderr);
175            }
176        }
177
178        Ok(())
179    }
180
181    /// Validate that the specified git references exist (branches, tags, or commits)
182    pub(crate) fn validate_refs(&self, refs: &[&str]) -> Result<()> {
183        for &git_ref in refs {
184            // Try to resolve the ref similar to `git checkout` by peeling to a commit.
185            // First try the ref as-is with ^{commit}, then fall back to origin/{ref}^{commit}.
186            let as_is = format!("{git_ref}^{{commit}}");
187            let ref_check = Command::new("git")
188                .args(["rev-parse", "--verify", &as_is])
189                .current_dir(&self.repo_root)
190                .output();
191
192            let found = if let Ok(output) = ref_check &&
193                output.status.success()
194            {
195                info!("Validated reference exists: {}", git_ref);
196                true
197            } else {
198                // Try remote-only branches via origin/{ref}
199                let origin_ref = format!("origin/{git_ref}^{{commit}}");
200                let origin_check = Command::new("git")
201                    .args(["rev-parse", "--verify", &origin_ref])
202                    .current_dir(&self.repo_root)
203                    .output();
204
205                if let Ok(output) = origin_check &&
206                    output.status.success()
207                {
208                    info!("Validated remote reference exists: origin/{}", git_ref);
209                    true
210                } else {
211                    false
212                }
213            };
214
215            if !found {
216                return Err(eyre!(
217                    "Git reference '{}' does not exist as branch, tag, or commit (tried '{}' and 'origin/{}^{{commit}}')",
218                    git_ref,
219                    format!("{git_ref}^{{commit}}"),
220                    git_ref,
221                ));
222            }
223        }
224
225        Ok(())
226    }
227
228    /// Switch to the specified git reference (branch, tag, or commit)
229    pub(crate) fn switch_ref(&self, git_ref: &str) -> Result<()> {
230        // First checkout the reference
231        let output = Command::new("git")
232            .args(["checkout", git_ref])
233            .current_dir(&self.repo_root)
234            .output()
235            .wrap_err_with(|| format!("Failed to switch to reference '{git_ref}'"))?;
236
237        if !output.status.success() {
238            let stderr = String::from_utf8_lossy(&output.stderr);
239            return Err(eyre!("Failed to switch to reference '{}': {}", git_ref, stderr));
240        }
241
242        // Check if this is a branch that tracks a remote and pull latest changes
243        let is_branch = Command::new("git")
244            .args(["show-ref", "--verify", "--quiet", &format!("refs/heads/{git_ref}")])
245            .current_dir(&self.repo_root)
246            .status()
247            .map(|s| s.success())
248            .unwrap_or(false);
249
250        if is_branch {
251            // Check if the branch tracks a remote
252            let tracking_output = Command::new("git")
253                .args([
254                    "rev-parse",
255                    "--abbrev-ref",
256                    "--symbolic-full-name",
257                    &format!("{git_ref}@{{upstream}}"),
258                ])
259                .current_dir(&self.repo_root)
260                .output();
261
262            if let Ok(output) = tracking_output &&
263                output.status.success()
264            {
265                let upstream = String::from_utf8_lossy(&output.stdout).trim().to_string();
266                if !upstream.is_empty() && upstream != format!("{git_ref}@{{upstream}}") {
267                    // Branch tracks a remote, pull latest changes
268                    info!("Pulling latest changes for branch: {}", git_ref);
269
270                    let pull_output = Command::new("git")
271                        .args(["pull", "--ff-only"])
272                        .current_dir(&self.repo_root)
273                        .output()
274                        .wrap_err_with(|| {
275                            format!("Failed to pull latest changes for branch '{git_ref}'")
276                        })?;
277
278                    if pull_output.status.success() {
279                        info!("Successfully pulled latest changes for branch: {}", git_ref);
280                    } else {
281                        let stderr = String::from_utf8_lossy(&pull_output.stderr);
282                        warn!("Failed to pull latest changes for branch '{}': {}", git_ref, stderr);
283                        // Continue anyway, we'll use whatever version we have
284                    }
285                }
286            }
287        }
288
289        // Verify the checkout succeeded by checking the current commit
290        let current_commit_output = Command::new("git")
291            .args(["rev-parse", "HEAD"])
292            .current_dir(&self.repo_root)
293            .output()
294            .wrap_err("Failed to get current commit")?;
295
296        if !current_commit_output.status.success() {
297            return Err(eyre!("Failed to verify git checkout"));
298        }
299
300        info!("Switched to reference: {}", git_ref);
301        Ok(())
302    }
303
304    /// Get the current commit hash
305    pub(crate) fn get_current_commit(&self) -> Result<String> {
306        let output = Command::new("git")
307            .args(["rev-parse", "HEAD"])
308            .current_dir(&self.repo_root)
309            .output()
310            .wrap_err("Failed to get current commit")?;
311
312        if !output.status.success() {
313            return Err(eyre!("Failed to get current commit hash"));
314        }
315
316        let commit_hash = String::from_utf8(output.stdout)
317            .wrap_err("Commit hash is not valid UTF-8")?
318            .trim()
319            .to_string();
320
321        Ok(commit_hash)
322    }
323
324    /// Get the repository root path
325    pub(crate) fn repo_root(&self) -> &str {
326        &self.repo_root
327    }
328}