1use std::env;
2use std::fs;
3use std::io::IsTerminal;
4use std::path::PathBuf;
5use std::process::Command;
6
7use anyhow::{Context, Result};
8use clap::CommandFactory;
9
10use crate::cli::Cli;
11use crate::prompt::confirm;
12
13const COMPLETION_MARKER: &str = "# added by git-stk setup";
16
17const POWERSHELL_LINE: &str = "if (Get-Command git-stk -ErrorAction SilentlyContinue) { git stk completions powershell | Out-String | Invoke-Expression }";
20
21pub fn setup(yes: bool, refresh: bool) -> Result<()> {
22 if refresh {
23 install_man_page()?;
28 return print_completion_hint();
29 }
30
31 install_man_page()?;
32 wire_completions(yes)?;
33 Ok(())
34}
35
36fn install_man_page() -> Result<()> {
39 if cfg!(windows) {
40 return Ok(());
41 }
42
43 let dir = man_dir()?;
44 fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
45
46 let mut buffer = Vec::new();
47 clap_mangen::Man::new(Cli::command())
48 .render(&mut buffer)
49 .context("failed to render man page")?;
50
51 let path = dir.join("git-stk.1");
52 fs::write(&path, buffer).with_context(|| format!("failed to write {}", path.display()))?;
53 anstream::println!("installed man page to {}", path.display());
54 Ok(())
55}
56
57fn man_dir() -> Result<PathBuf> {
58 let data_home = env::var_os("XDG_DATA_HOME")
59 .map(PathBuf::from)
60 .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
61 .context("cannot locate a data directory; set HOME or XDG_DATA_HOME")?;
62 Ok(data_home.join("man/man1"))
63}
64
65fn wire_completions(yes: bool) -> Result<()> {
67 let Some((shell, rc_path, line)) = completion_target()? else {
68 anstream::println!("could not detect a supported shell");
69 anstream::println!("see the README for manual completion setup");
70 return Ok(());
71 };
72
73 let existing = match fs::read_to_string(&rc_path) {
74 Ok(contents) => contents,
75 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
76 Err(error) => {
77 return Err(error).with_context(|| format!("failed to read {}", rc_path.display()));
78 }
79 };
80
81 if existing.contains(COMPLETION_MARKER) || existing.contains("git stk completions") {
82 anstream::println!(
83 "{shell} completions already configured in {}",
84 rc_path.display()
85 );
86 return Ok(());
87 }
88
89 let interactive = std::io::stdin().is_terminal();
94 let proceed = if yes {
95 true
96 } else if interactive {
97 confirm(&format!(
98 "append completion setup to {}? [y/N] ",
99 rc_path.display()
100 ))?
101 } else {
102 false
103 };
104 if !proceed {
105 anstream::println!(
106 "{}",
107 if interactive {
108 "skipped completion setup"
109 } else {
110 "non-interactive shell; skipped completion setup"
111 }
112 );
113 anstream::println!("to configure manually, add this to {}:", rc_path.display());
114 anstream::println!(" {line}");
115 return Ok(());
116 }
117
118 let mut updated = existing;
119 if !updated.is_empty() && !updated.ends_with('\n') {
120 updated.push('\n');
121 }
122 updated.push_str(&format!("\n{COMPLETION_MARKER}\n{line}\n"));
123 if let Some(parent) = rc_path.parent() {
126 fs::create_dir_all(parent)
127 .with_context(|| format!("failed to create {}", parent.display()))?;
128 }
129 fs::write(&rc_path, updated)
130 .with_context(|| format!("failed to write {}", rc_path.display()))?;
131 anstream::println!("added {shell} completion setup to {}", rc_path.display());
132 Ok(())
133}
134
135fn print_completion_hint() -> Result<()> {
138 let Some((shell, rc_path, line)) = completion_target()? else {
139 return Ok(());
140 };
141
142 let configured = fs::read_to_string(&rc_path)
143 .map(|rc| rc.contains(COMPLETION_MARKER) || rc.contains("git stk completions"))
144 .unwrap_or(false);
145 if configured {
146 return Ok(());
147 }
148
149 anstream::println!(
150 "{shell} completions are not configured; run `git stk setup`, \
151 or add this to {}:",
152 rc_path.display()
153 );
154 anstream::println!(" {line}");
155 Ok(())
156}
157
158fn completion_target() -> Result<Option<(&'static str, PathBuf, &'static str)>> {
163 if let Some(target) = posix_shell_target() {
164 return Ok(Some(target));
165 }
166 Ok(powershell_target())
167}
168
169fn posix_shell_target() -> Option<(&'static str, PathBuf, &'static str)> {
173 let shell = env::var("SHELL").unwrap_or_default();
174 let shell = shell.rsplit('/').next().unwrap_or_default();
175 let home = env::var_os("HOME").map(PathBuf::from)?;
176
177 match shell {
178 "bash" => Some((
179 "bash",
180 home.join(".bashrc"),
181 "command -v git-stk >/dev/null && source <(git stk completions bash)",
182 )),
183 "zsh" => Some((
184 "zsh",
185 home.join(".zshrc"),
186 "command -v git-stk >/dev/null && source <(git stk completions zsh)",
187 )),
188 "fish" => Some((
189 "fish",
190 home.join(".config/fish/config.fish"),
191 "command -q git-stk; and git stk completions fish | source",
192 )),
193 _ => None,
194 }
195}
196
197fn powershell_target() -> Option<(&'static str, PathBuf, &'static str)> {
200 for exe in ["pwsh", "powershell"] {
201 let Ok(output) = Command::new(exe)
202 .args(["-NoProfile", "-Command", "$PROFILE"])
203 .output()
204 else {
205 continue;
206 };
207 if !output.status.success() {
208 continue;
209 }
210 let path = String::from_utf8_lossy(&output.stdout).trim().to_owned();
211 if !path.is_empty() {
212 return Some(("PowerShell", PathBuf::from(path), POWERSHELL_LINE));
213 }
214 }
215 None
216}
217
218pub fn uninstall(dry_run: bool, yes: bool) -> Result<()> {
224 let completion = match completion_target()? {
227 Some((shell, rc_path, _line)) => match fs::read_to_string(&rc_path) {
228 Ok(contents) if contents.contains(COMPLETION_MARKER) => {
229 Some((shell, rc_path, contents))
230 }
231 _ => None,
232 },
233 None => None,
234 };
235 let man_page = man_dir()
236 .ok()
237 .map(|dir| dir.join("git-stk.1"))
238 .filter(|p| p.exists());
239 let config_dir = crate::upgrade::config_dir().filter(|p| p.exists());
240
241 anstream::println!("git stk uninstall removes what setup and the installer added:");
242 let mut anything = false;
243 if let Some((shell, rc_path, _)) = &completion {
244 anstream::println!(" - {shell} completion line in {}", rc_path.display());
245 anything = true;
246 }
247 if let Some(path) = &man_page {
248 anstream::println!(" - man page {}", path.display());
249 anything = true;
250 }
251 if let Some(dir) = &config_dir {
252 anstream::println!(" - config and install receipt in {}", dir.display());
253 anything = true;
254 }
255 if !anything {
256 anstream::println!(" (nothing found - already removed, or installed another way)");
257 }
258
259 if dry_run {
260 anstream::println!("dry run: nothing was removed");
261 print_binary_note();
262 return Ok(());
263 }
264 if anything && !yes && !confirm("remove these? [y/N] ")? {
265 anstream::println!("uninstall cancelled");
266 print_binary_note();
267 return Ok(());
268 }
269
270 if let Some((shell, rc_path, contents)) = completion
271 && let Some(stripped) = strip_completion_block(&contents)
272 {
273 fs::write(&rc_path, stripped)
274 .with_context(|| format!("failed to update {}", rc_path.display()))?;
275 anstream::println!("removed {shell} completion line from {}", rc_path.display());
276 }
277 if let Some(path) = man_page {
278 fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
279 anstream::println!("removed man page {}", path.display());
280 }
281 if let Some(dir) = config_dir {
282 fs::remove_dir_all(&dir).with_context(|| format!("failed to remove {}", dir.display()))?;
283 anstream::println!("removed {}", dir.display());
284 }
285
286 print_binary_note();
287 Ok(())
288}
289
290fn print_binary_note() {
294 anstream::println!();
295 match env::current_exe() {
296 Ok(path) => {
297 anstream::println!("the git-stk binary is left in place; remove it with:");
298 anstream::println!(" rm {}", path.display());
299 }
300 Err(_) => anstream::println!("remove the git-stk binary from your PATH to finish."),
301 }
302 anstream::println!(
303 "(or `cargo uninstall git-stk` / `brew uninstall git-stk` if you installed it that way)"
304 );
305 anstream::println!("per-repo stk.* config and branch metadata are left untouched.");
306}
307
308fn strip_completion_block(contents: &str) -> Option<String> {
312 let lines: Vec<&str> = contents.lines().collect();
313 let marker = lines
314 .iter()
315 .position(|line| line.trim() == COMPLETION_MARKER)?;
316 let start = marker.saturating_sub(usize::from(
319 marker > 0 && lines[marker - 1].trim().is_empty(),
320 ));
321 let end = (marker + 2).min(lines.len());
322
323 let mut kept = lines[..start].to_vec();
324 kept.extend_from_slice(&lines[end..]);
325 let mut result = kept.join("\n");
326 if !result.is_empty() && contents.ends_with('\n') {
327 result.push('\n');
328 }
329 Some(result)
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn strip_removes_the_marked_block_setup_wrote() {
338 let rc = "export PATH=/x\n\n# added by git-stk setup\ncommand -v git-stk >/dev/null && source <(git stk completions bash)\n";
341 assert_eq!(strip_completion_block(rc).unwrap(), "export PATH=/x\n");
342 }
343
344 #[test]
345 fn strip_leaves_other_content_intact() {
346 let rc = "# added by git-stk setup\ncommand -v git-stk\nalias g=git\n";
348 assert_eq!(strip_completion_block(rc).unwrap(), "alias g=git\n");
349 }
350
351 #[test]
352 fn strip_returns_none_without_the_marker() {
353 assert_eq!(strip_completion_block("export PATH=/x\n"), None);
354 }
355}