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    fn client_kind(&self) -> String {
60        "local".to_string()
61    }
62
63    #[cfg(feature = "file-changes")]
64    async fn get_list_of_changed_files(
65        &self,
66        file_filter: &FileFilter,
67        lines_changed_only: &LinesChangedOnly,
68        base_diff: Option<String>,
69        ignore_index: bool,
70    ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
71        let git_status = if ignore_index {
72            0
73        } else {
74            match Command::new("git").args(["status", "--short"]).output() {
75                Err(e) => {
76                    return Err(ClientError::io("invoke `git status`", e));
77                }
78                Ok(output) => {
79                    if output.status.success() {
80                        String::from_utf8_lossy(&output.stdout)
81                            .to_string()
82                            // trim last newline to prevent an extra empty line being counted as a changed file
83                            .trim_end_matches('\n')
84                            .lines()
85                            // we only care about staged changes
86                            .filter(|l| !l.starts_with(' '))
87                            .count()
88                    } else {
89                        let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
90                        return Err(ClientError::GitCommand(err_msg));
91                    }
92                }
93            }
94        };
95        let mut diff_args = vec![];
96        let mut git_sub_cmd = vec!["--no-pager"];
97        if git_status != 0 {
98            // There are changes in the working directory.
99            // So, compare include the staged changes.
100            diff_args.push("--staged".to_string());
101        }
102        if let Some(base) = base_diff {
103            let resolved_base = git_rev_parse(&base)?;
104            diff_args.push(resolved_base);
105        } else if git_status == 0 {
106            // No base diff provided and there are no staged changes,
107            // just get the diff of the last commit.
108            let resolved_head = git_rev_parse("HEAD~1")?;
109            diff_args.push(resolved_head);
110        }
111        if ignore_index {
112            // When ignoring the index, we want to compare
113            // the working directory changes, not the staged changes.
114            diff_args.push("--format=%b".to_string());
115            git_sub_cmd.push("show");
116        } else {
117            git_sub_cmd.push("diff");
118        };
119        log::debug!(
120            "Getting diff with `git {} {}`",
121            git_sub_cmd.join(" "),
122            diff_args.join(" ")
123        );
124        match Command::new("git")
125            .args(&git_sub_cmd)
126            .args(&diff_args)
127            .output()
128        {
129            Err(e) => Err(ClientError::Io {
130                task: format!(
131                    "invoke `git {} {}`",
132                    git_sub_cmd.join(" "),
133                    diff_args.join(" ")
134                ),
135                source: e,
136            }),
137            Ok(output) => {
138                if output.status.success() {
139                    let diff_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
140                    let files = parse_diff(&diff_str, file_filter, lines_changed_only)?;
141                    Ok(files)
142                } else {
143                    let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
144                    Err(ClientError::GitCommand(err_msg))
145                }
146            }
147        }
148    }
149
150    fn is_pr_event(&self) -> bool {
151        false
152    }
153
154    fn set_user_agent(&mut self, _user_agent: &str) -> Result<(), ClientError> {
155        Ok(())
156    }
157
158    async fn post_thread_comment(&self, _options: ThreadCommentOptions) -> Result<(), ClientError> {
159        Ok(())
160    }
161
162    async fn cull_pr_reviews(&mut self, _options: &mut ReviewOptions) -> Result<(), ClientError> {
163        Ok(())
164    }
165
166    async fn post_pr_review(&mut self, _options: &ReviewOptions) -> Result<(), ClientError> {
167        Ok(())
168    }
169
170    fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
171        for var in vars {
172            log::info!("{}: {}", var.name, var.value);
173        }
174        Ok(())
175    }
176}