Skip to main content

git_bot_feedback/client/
local.rs

1use super::RestApiClient;
2use crate::{OutputVariable, RestClientError as ClientError, ReviewOptions, ThreadCommentOptions};
3
4#[cfg(feature = "file-changes")]
5use crate::{FileDiffLines, FileFilter, LinesChangedOnly, parse_diff};
6#[cfg(feature = "file-changes")]
7use std::{collections::HashMap, process::Command};
8
9/// A (mostly) non-operational implementation of [`RestApiClient`].
10///
11/// This is primarily meant for use in local contexts (or in unsupported CI
12/// platforms/contexts) because the following methods silently do nothing:
13///
14/// - [`Self::post_thread_comment`]
15/// - [`Self::cull_pr_reviews`]
16/// - [`Self::post_pr_review`]
17/// - [`Self::set_user_agent`]
18///
19/// However, [`Self::get_list_of_changed_files`] does use the git CLI
20/// to get a list of changed files.
21///
22/// Instantiate with [`Default::default()`].
23/// ```rust
24/// use git_bot_feedback::client::LocalClient;
25///
26/// let client = LocalClient::default();
27/// ```
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct LocalClient;
30
31/// Helper function to resolve a git reference to a commit hash using `git rev-parse`.
32#[cfg(feature = "file-changes")]
33fn git_rev_parse(base: &str) -> Result<String, ClientError> {
34    match Command::new("git").args(["rev-parse", base]).output() {
35        Err(e) => Err(ClientError::Io {
36            task: format!("invoke `git rev-parse {base}` to validate reference"),
37            source: e,
38        }),
39        Ok(output) => {
40            if output.status.success() {
41                Ok(String::from_utf8_lossy(output.stdout.trim_ascii()).to_string())
42            } else if base.chars().all(|c| c.is_ascii_digit()) {
43                // if all chars from a decimal number, then
44                // try using it as a number of parents from HEAD.
45                // This is not infinitely recursive because
46                // the adapted `base` is not all digits.
47                git_rev_parse(format!("HEAD~{base}").as_str())
48            } else {
49                let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
50                // Given diff base did not resolve to a valid git reference
51                Err(ClientError::GitCommand(err_msg))
52            }
53        }
54    }
55}
56
57#[async_trait::async_trait]
58impl RestApiClient for LocalClient {
59    #[cfg(feature = "file-changes")]
60    async fn get_list_of_changed_files(
61        &self,
62        file_filter: &FileFilter,
63        lines_changed_only: &LinesChangedOnly,
64        base_diff: Option<String>,
65        ignore_index: bool,
66    ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
67        let git_status = if ignore_index {
68            0
69        } else {
70            match Command::new("git").args(["status", "--short"]).output() {
71                Err(e) => {
72                    return Err(ClientError::io("invoke `git status`", e));
73                }
74                Ok(output) => {
75                    if output.status.success() {
76                        String::from_utf8_lossy(&output.stdout)
77                            .to_string()
78                            // trim last newline to prevent an extra empty line being counted as a changed file
79                            .trim_end_matches('\n')
80                            .lines()
81                            // we only care about staged changes
82                            .filter(|l| !l.starts_with(' '))
83                            .count()
84                    } else {
85                        let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
86                        return Err(ClientError::GitCommand(err_msg));
87                    }
88                }
89            }
90        };
91        let mut diff_args = vec![];
92        let mut git_sub_cmd = vec!["--no-pager"];
93        if git_status != 0 {
94            // There are changes in the working directory.
95            // So, compare include the staged changes.
96            diff_args.push("--staged".to_string());
97        }
98        if let Some(base) = base_diff {
99            let resolved_base = git_rev_parse(&base)?;
100            diff_args.push(resolved_base);
101        } else if git_status == 0 {
102            // No base diff provided and there are no staged changes,
103            // just get the diff of the last commit.
104            let resolved_head = git_rev_parse("HEAD~1")?;
105            diff_args.push(resolved_head);
106        }
107        if ignore_index {
108            // When ignoring the index, we want to compare
109            // the working directory changes, not the staged changes.
110            diff_args.push("--format=%b".to_string());
111            git_sub_cmd.push("show");
112        } else {
113            git_sub_cmd.push("diff");
114        };
115        log::debug!(
116            "Getting diff with `git {} {}`",
117            git_sub_cmd.join(" "),
118            diff_args.join(" ")
119        );
120        match Command::new("git")
121            .args(&git_sub_cmd)
122            .args(&diff_args)
123            .output()
124        {
125            Err(e) => Err(ClientError::Io {
126                task: format!(
127                    "invoke `git {} {}`",
128                    git_sub_cmd.join(" "),
129                    diff_args.join(" ")
130                ),
131                source: e,
132            }),
133            Ok(output) => {
134                if output.status.success() {
135                    let diff_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
136                    let files = parse_diff(&diff_str, file_filter, lines_changed_only)?;
137                    Ok(files)
138                } else {
139                    let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
140                    Err(ClientError::GitCommand(err_msg))
141                }
142            }
143        }
144    }
145
146    fn is_pr_event(&self) -> bool {
147        false
148    }
149
150    fn set_user_agent(&mut self, _user_agent: &str) -> Result<(), ClientError> {
151        Ok(())
152    }
153
154    async fn post_thread_comment(&self, _options: ThreadCommentOptions) -> Result<(), ClientError> {
155        Ok(())
156    }
157
158    async fn cull_pr_reviews(&mut self, _options: &mut ReviewOptions) -> Result<(), ClientError> {
159        Ok(())
160    }
161
162    async fn post_pr_review(&mut self, _options: &ReviewOptions) -> Result<(), ClientError> {
163        Ok(())
164    }
165
166    fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
167        for var in vars {
168            log::info!("{}: {}", var.name, var.value);
169        }
170        Ok(())
171    }
172}