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(|| {
61 env::var_os("HOME").map(|home| PathBuf::from(home).join(".local").join("share"))
62 })
63 .or_else(|| env::var_os("LOCALAPPDATA").map(PathBuf::from))
65 .context("cannot locate a data directory; set HOME, XDG_DATA_HOME, or LOCALAPPDATA")?;
66 Ok(data_home.join("man").join("man1"))
67}
68
69fn wire_completions(yes: bool) -> Result<()> {
71 let Some((shell, rc_path, line)) = completion_target()? else {
72 anstream::println!("could not detect a supported shell");
73 anstream::println!("see the README for manual completion setup");
74 return Ok(());
75 };
76
77 let existing = match fs::read_to_string(&rc_path) {
78 Ok(contents) => contents,
79 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
80 Err(error) => {
81 return Err(error).with_context(|| format!("failed to read {}", rc_path.display()));
82 }
83 };
84
85 if existing.contains(COMPLETION_MARKER) || existing.contains("git stk completions") {
86 anstream::println!(
87 "{shell} completions already configured in {}",
88 rc_path.display()
89 );
90 return Ok(());
91 }
92
93 let interactive = std::io::stdin().is_terminal();
98 let proceed = if yes {
99 true
100 } else if interactive {
101 confirm(&format!(
102 "append completion setup to {}? [y/N] ",
103 rc_path.display()
104 ))?
105 } else {
106 false
107 };
108 if !proceed {
109 anstream::println!(
110 "{}",
111 if interactive {
112 "skipped completion setup"
113 } else {
114 "non-interactive shell; skipped completion setup"
115 }
116 );
117 anstream::println!("to configure manually, add this to {}:", rc_path.display());
118 anstream::println!(" {line}");
119 return Ok(());
120 }
121
122 let mut updated = existing;
123 if !updated.is_empty() && !updated.ends_with('\n') {
124 updated.push('\n');
125 }
126 updated.push_str(&format!("\n{COMPLETION_MARKER}\n{line}\n"));
127 if let Some(parent) = rc_path.parent() {
130 fs::create_dir_all(parent)
131 .with_context(|| format!("failed to create {}", parent.display()))?;
132 }
133 fs::write(&rc_path, updated)
134 .with_context(|| format!("failed to write {}", rc_path.display()))?;
135 anstream::println!("added {shell} completion setup to {}", rc_path.display());
136 Ok(())
137}
138
139fn print_completion_hint() -> Result<()> {
142 let Some((shell, rc_path, line)) = completion_target()? else {
143 return Ok(());
144 };
145
146 let configured = fs::read_to_string(&rc_path)
147 .map(|rc| rc.contains(COMPLETION_MARKER) || rc.contains("git stk completions"))
148 .unwrap_or(false);
149 if configured {
150 return Ok(());
151 }
152
153 anstream::println!(
154 "{shell} completions are not configured; run `git stk setup`, \
155 or add this to {}:",
156 rc_path.display()
157 );
158 anstream::println!(" {line}");
159 Ok(())
160}
161
162fn completion_target() -> Result<Option<(&'static str, PathBuf, &'static str)>> {
167 if let Some(target) = posix_shell_target() {
168 return Ok(Some(target));
169 }
170 Ok(powershell_target())
171}
172
173fn posix_shell_target() -> Option<(&'static str, PathBuf, &'static str)> {
177 let shell = env::var("SHELL").unwrap_or_default();
178 let shell = shell.rsplit('/').next().unwrap_or_default();
179 let home = env::var_os("HOME").map(PathBuf::from)?;
180
181 match shell {
182 "bash" => Some((
183 "bash",
184 home.join(".bashrc"),
185 "command -v git-stk >/dev/null && source <(git stk completions bash)",
186 )),
187 "zsh" => Some((
188 "zsh",
189 home.join(".zshrc"),
190 "command -v git-stk >/dev/null && source <(git stk completions zsh)",
191 )),
192 "fish" => Some((
193 "fish",
194 home.join(".config/fish/config.fish"),
195 "command -q git-stk; and git stk completions fish | source",
196 )),
197 _ => None,
198 }
199}
200
201fn powershell_target() -> Option<(&'static str, PathBuf, &'static str)> {
204 for exe in ["pwsh", "powershell"] {
205 let Ok(output) = Command::new(exe)
206 .args(["-NoProfile", "-Command", "$PROFILE"])
207 .output()
208 else {
209 continue;
210 };
211 if !output.status.success() {
212 continue;
213 }
214 let path = String::from_utf8_lossy(&output.stdout).trim().to_owned();
215 if !path.is_empty() {
216 return Some(("PowerShell", PathBuf::from(path), POWERSHELL_LINE));
217 }
218 }
219 None
220}
221
222pub fn uninstall(dry_run: bool, yes: bool) -> Result<()> {
228 let completion = match completion_target()? {
231 Some((shell, rc_path, _line)) => match fs::read_to_string(&rc_path) {
232 Ok(contents) if contents.contains(COMPLETION_MARKER) => {
233 Some((shell, rc_path, contents))
234 }
235 _ => None,
236 },
237 None => None,
238 };
239 let man_page = man_dir()
240 .ok()
241 .map(|dir| dir.join("git-stk.1"))
242 .filter(|p| p.exists());
243 let config_dir = crate::upgrade::config_dir().filter(|p| p.exists());
244
245 anstream::println!("git stk uninstall removes what setup and the installer added:");
246 let mut anything = false;
247 if let Some((shell, rc_path, _)) = &completion {
248 anstream::println!(" - {shell} completion line in {}", rc_path.display());
249 anything = true;
250 }
251 if let Some(path) = &man_page {
252 anstream::println!(" - man page {}", path.display());
253 anything = true;
254 }
255 if let Some(dir) = &config_dir {
256 anstream::println!(" - config and install receipt in {}", dir.display());
257 anything = true;
258 }
259 if !anything {
260 anstream::println!(" (nothing found - already removed, or installed another way)");
261 }
262
263 if dry_run {
264 anstream::println!("dry run: nothing was removed");
265 print_binary_note();
266 return Ok(());
267 }
268 if anything && !yes && !confirm("remove these? [y/N] ")? {
269 anstream::println!("uninstall cancelled");
270 print_binary_note();
271 return Ok(());
272 }
273
274 if let Some((shell, rc_path, contents)) = completion
275 && let Some(stripped) = strip_completion_block(&contents)
276 {
277 fs::write(&rc_path, stripped)
278 .with_context(|| format!("failed to update {}", rc_path.display()))?;
279 anstream::println!("removed {shell} completion line from {}", rc_path.display());
280 }
281 if let Some(path) = man_page {
282 fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
283 anstream::println!("removed man page {}", path.display());
284 }
285 if let Some(dir) = config_dir {
286 fs::remove_dir_all(&dir).with_context(|| format!("failed to remove {}", dir.display()))?;
287 anstream::println!("removed {}", dir.display());
288 }
289
290 print_binary_note();
291 Ok(())
292}
293
294fn print_binary_note() {
298 anstream::println!();
299 match env::current_exe() {
300 Ok(path) => {
301 anstream::println!("the git-stk binary is left in place; remove it with:");
302 if cfg!(windows) {
303 anstream::println!(" Remove-Item \"{}\"", path.display());
304 } else {
305 anstream::println!(" rm {}", path.display());
306 }
307 }
308 Err(_) => anstream::println!("remove the git-stk binary from your PATH to finish."),
309 }
310 anstream::println!(
311 "(or `cargo uninstall git-stk` / `brew uninstall git-stk` if you installed it that way)"
312 );
313 anstream::println!("per-repo stk.* config and branch metadata are left untouched.");
314}
315
316fn strip_completion_block(contents: &str) -> Option<String> {
320 let lines: Vec<&str> = contents.lines().collect();
321 let marker = lines
322 .iter()
323 .position(|line| line.trim() == COMPLETION_MARKER)?;
324
325 let removes_completion_line = lines
330 .get(marker + 1)
331 .is_some_and(|line| line.contains("git stk completions"));
332 let end = (marker + 1 + usize::from(removes_completion_line)).min(lines.len());
333 let start = marker.saturating_sub(usize::from(
335 marker > 0 && lines[marker - 1].trim().is_empty(),
336 ));
337
338 let mut kept = lines[..start].to_vec();
339 kept.extend_from_slice(&lines[end..]);
340 let mut result = kept.join("\n");
341 if !result.is_empty() && contents.ends_with('\n') {
342 result.push('\n');
343 }
344 Some(result)
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn strip_removes_the_marked_block_setup_wrote() {
353 let rc = "export PATH=/x\n\n# added by git-stk setup\ncommand -v git-stk >/dev/null && source <(git stk completions bash)\n";
356 assert_eq!(strip_completion_block(rc).unwrap(), "export PATH=/x\n");
357 }
358
359 #[test]
360 fn strip_leaves_content_after_the_block_intact() {
361 let rc = "# added by git-stk setup\ncommand -v git-stk >/dev/null && source <(git stk completions zsh)\nalias g=git\n";
363 assert_eq!(strip_completion_block(rc).unwrap(), "alias g=git\n");
364 }
365
366 #[test]
367 fn strip_keeps_a_hand_edited_line_after_an_orphaned_marker() {
368 let rc = "# added by git-stk setup\nalias g=git\n";
371 assert_eq!(strip_completion_block(rc).unwrap(), "alias g=git\n");
372 }
373
374 #[test]
375 fn strip_returns_none_without_the_marker() {
376 assert_eq!(strip_completion_block("export PATH=/x\n"), None);
377 }
378}