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