|
| 1 | +use std::collections::HashMap; |
| 2 | +use std::io::Write; |
| 3 | +use std::process::Stdio; |
| 4 | + |
| 5 | +use bstr::ByteSlice; |
| 6 | +use crossterm::{ |
| 7 | + queue, |
| 8 | + style, |
| 9 | +}; |
| 10 | +use eyre::{ |
| 11 | + Context as _, |
| 12 | + Result, |
| 13 | +}; |
| 14 | +use fig_os_shim::Context; |
| 15 | +use serde::Deserialize; |
| 16 | + |
| 17 | +use super::{ |
| 18 | + InvokeOutput, |
| 19 | + OutputKind, |
| 20 | +}; |
| 21 | + |
| 22 | +const READONLY_COMMANDS: [&str; 12] = [ |
| 23 | + "status", |
| 24 | + "log", |
| 25 | + "show", |
| 26 | + "diff", |
| 27 | + "grep", |
| 28 | + "ls-files", |
| 29 | + "ls-remote", |
| 30 | + "rev-parse", |
| 31 | + "blame", |
| 32 | + "describe", |
| 33 | + "cat-file", |
| 34 | + "check-ignore", |
| 35 | +]; |
| 36 | + |
| 37 | +const READONLY_SUBCOMMANDS: [(&str, &str); 4] = [ |
| 38 | + ("config", "get"), |
| 39 | + ("config", "list"), |
| 40 | + ("remote", "show"), |
| 41 | + ("remote", "get-url"), |
| 42 | +]; |
| 43 | + |
| 44 | +#[derive(Debug, Clone, Deserialize)] |
| 45 | +pub struct Git { |
| 46 | + pub command: String, |
| 47 | + pub subcommand: Option<String>, |
| 48 | + pub repo: Option<String>, |
| 49 | + pub branch: Option<String>, |
| 50 | + pub parameters: Option<HashMap<String, serde_json::Value>>, |
| 51 | + pub label: Option<String>, |
| 52 | +} |
| 53 | + |
| 54 | +impl Git { |
| 55 | + pub fn requires_consent(&self) -> bool { |
| 56 | + if READONLY_COMMANDS.contains(&self.command.as_str()) { |
| 57 | + return false; |
| 58 | + } |
| 59 | + |
| 60 | + match (self.command.as_str(), self.subcommand.as_ref()) { |
| 61 | + ("branch" | "tag" | "remote", None) |
| 62 | + if self.parameters.is_none() || self.parameters.as_ref().is_some_and(|p| p.is_empty()) => |
| 63 | + { |
| 64 | + false |
| 65 | + }, |
| 66 | + (cmd, Some(sub_command)) => !READONLY_SUBCOMMANDS.contains(&(cmd, sub_command)), |
| 67 | + _ => true, |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + pub async fn invoke(&self, _ctx: &Context, _updates: impl Write) -> Result<InvokeOutput> { |
| 72 | + let mut command = tokio::process::Command::new("git"); |
| 73 | + command.arg(&self.command); |
| 74 | + if let Some(subcommand) = self.subcommand.as_ref() { |
| 75 | + command.arg(subcommand); |
| 76 | + } |
| 77 | + if let Some(repo) = self.repo.as_ref() { |
| 78 | + command.arg(repo); |
| 79 | + } |
| 80 | + if let Some(branch) = self.branch.as_ref() { |
| 81 | + command.arg(branch); |
| 82 | + } |
| 83 | + if let Some(parameters) = self.parameters.as_ref() { |
| 84 | + for (name, val) in parameters { |
| 85 | + let param_name = format!("--{}", name.trim_start_matches("--")); |
| 86 | + let param_val = val.as_str().map(|s| s.to_string()).unwrap_or(val.to_string()); |
| 87 | + command.arg(param_name).arg(param_val); |
| 88 | + } |
| 89 | + } |
| 90 | + let output = command |
| 91 | + .stdout(Stdio::piped()) |
| 92 | + .stderr(Stdio::piped()) |
| 93 | + .spawn() |
| 94 | + .wrap_err_with(|| format!("Unable to spawn command '{:?}'", self))? |
| 95 | + .wait_with_output() |
| 96 | + .await |
| 97 | + .wrap_err_with(|| format!("Unable to spawn command '{:?}'", self))?; |
| 98 | + let status = output.stdout.to_str_lossy(); |
| 99 | + let stdout = output.stdout.to_str_lossy(); |
| 100 | + let stderr = output.stderr.to_str_lossy(); |
| 101 | + |
| 102 | + Ok(InvokeOutput { |
| 103 | + output: OutputKind::Json(serde_json::json!({ |
| 104 | + "exit_status": status, |
| 105 | + "stdout": stdout, |
| 106 | + "stderr": stderr.clone() |
| 107 | + })), |
| 108 | + }) |
| 109 | + } |
| 110 | + |
| 111 | + pub fn queue_description(&self, updates: &mut impl Write) -> Result<()> { |
| 112 | + if let Some(label) = self.label.as_ref() { |
| 113 | + queue!(updates, style::Print(label))?; |
| 114 | + } |
| 115 | + queue!( |
| 116 | + updates, |
| 117 | + style::Print("\n"), |
| 118 | + style::Print(format!("Command: git {}", self.command)) |
| 119 | + )?; |
| 120 | + if let Some(subcommand) = self.subcommand.as_ref() { |
| 121 | + queue!(updates, style::Print(format!(" {}", subcommand)))?; |
| 122 | + } |
| 123 | + if let Some(repo) = self.repo.as_ref() { |
| 124 | + queue!(updates, style::Print(format!(" {}", repo)))?; |
| 125 | + } |
| 126 | + if let Some(branch) = self.branch.as_ref() { |
| 127 | + queue!(updates, style::Print(format!(" {}", branch)))?; |
| 128 | + } |
| 129 | + if let Some(parameters) = self.parameters.as_ref() { |
| 130 | + for (name, val) in parameters { |
| 131 | + let param_name = format!("--{}", name.trim_start_matches("--")); |
| 132 | + let param_val = val.as_str().map(|s| s.to_string()).unwrap_or(val.to_string()); |
| 133 | + queue!(updates, style::Print(format!(" {} {}", param_name, param_val)))?; |
| 134 | + } |
| 135 | + } |
| 136 | + Ok(()) |
| 137 | + } |
| 138 | + |
| 139 | + pub async fn validate(&mut self, _ctx: &Context) -> Result<()> { |
| 140 | + Ok(()) |
| 141 | + } |
| 142 | +} |
| 143 | + |
| 144 | +#[cfg(test)] |
| 145 | +mod tests { |
| 146 | + use super::*; |
| 147 | + |
| 148 | + macro_rules! git { |
| 149 | + ($value:tt) => { |
| 150 | + serde_json::from_value::<Git>(serde_json::json!($value)).unwrap() |
| 151 | + }; |
| 152 | + } |
| 153 | + |
| 154 | + #[test] |
| 155 | + fn test_requires_consent() { |
| 156 | + let readonly_commands = [ |
| 157 | + git!({"command": "log"}), |
| 158 | + git!({"command": "status"}), |
| 159 | + git!({"command": "diff"}), |
| 160 | + git!({"command": "show"}), |
| 161 | + git!({"command": "ls-files"}), |
| 162 | + git!({"command": "branch"}), |
| 163 | + git!({"command": "tag"}), |
| 164 | + git!({"command": "remote"}), |
| 165 | + git!({"command": "blame", "parameters": {"file": "src/main.rs"}}), |
| 166 | + git!({"command": "rev-parse", "parameters": {"show-toplevel": true}}), |
| 167 | + git!({"command": "ls-remote", "repo": "origin"}), |
| 168 | + git!({"command": "config", "subcommand": "get", "parameters": {"name": "user.email"}}), |
| 169 | + git!({"command": "config", "subcommand": "list"}), |
| 170 | + git!({"command": "describe", "parameters": {"tags": true}}), |
| 171 | + ]; |
| 172 | + |
| 173 | + for cmd in readonly_commands { |
| 174 | + assert!(!cmd.requires_consent(), "Command should not require consent: {:?}", cmd); |
| 175 | + } |
| 176 | + |
| 177 | + let write_commands = [ |
| 178 | + git!({"command": "commit", "parameters": {"message": "Initial commit"}}), |
| 179 | + git!({"command": "push", "repo": "origin", "branch": "main"}), |
| 180 | + git!({"command": "pull", "repo": "origin", "branch": "main"}), |
| 181 | + git!({"command": "merge", "branch": "feature"}), |
| 182 | + git!({"command": "branch", "subcommand": "create", "branch": "new-feature"}), |
| 183 | + git!({"command": "branch", "parameters": {"-D": true}, "branch": "old-feature"}), |
| 184 | + git!({"command": "branch", "parameters": {"--delete": true}, "branch": "old-feature"}), |
| 185 | + git!({"command": "checkout", "branch": "develop"}), |
| 186 | + git!({"command": "switch", "branch": "develop"}), |
| 187 | + git!({"command": "reset", "parameters": {"hard": true}}), |
| 188 | + git!({"command": "clean", "parameters": {"-fd": true}}), |
| 189 | + git!({"command": "clone", "repo": "https://github.com/user/repo.git"}), |
| 190 | + git!({"command": "remote", "subcommand": "add", "repo": "https://github.com/user/repo.git", "parameters": {"name": "upstream"}}), |
| 191 | + git!({"command": "config", "subcommand": "set", "parameters": {"name": "user.email", "value": "[email protected]"}}), |
| 192 | + ]; |
| 193 | + |
| 194 | + for cmd in write_commands { |
| 195 | + assert!(cmd.requires_consent(), "Command should require consent: {:?}", cmd); |
| 196 | + } |
| 197 | + } |
| 198 | +} |
0 commit comments