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 if shell == "PowerShell"
99 && let Some(policy) = powershell_execution_policy()
100 && policy_blocks_profile(&policy)
101 {
102 anstream::println!(
103 "PowerShell's execution policy ({policy}) blocks profile scripts, so \
104 completions can't be enabled without breaking shell startup."
105 );
106 anstream::println!(
107 "allow your profile to run (per-user, no admin needed), then re-run `git stk setup`:"
108 );
109 anstream::println!(" Set-ExecutionPolicy -Scope CurrentUser RemoteSigned");
110 anstream::println!("or add this line to {} yourself:", rc_path.display());
111 anstream::println!(" {line}");
112 return Ok(());
113 }
114
115 let interactive = std::io::stdin().is_terminal();
120 let proceed = if yes {
121 true
122 } else if interactive {
123 confirm(&format!(
124 "append completion setup to {}? [y/N] ",
125 rc_path.display()
126 ))?
127 } else {
128 false
129 };
130 if !proceed {
131 anstream::println!(
132 "{}",
133 if interactive {
134 "skipped completion setup"
135 } else {
136 "non-interactive shell; skipped completion setup"
137 }
138 );
139 anstream::println!("to configure manually, add this to {}:", rc_path.display());
140 anstream::println!(" {line}");
141 return Ok(());
142 }
143
144 let mut updated = existing;
145 if !updated.is_empty() && !updated.ends_with('\n') {
146 updated.push('\n');
147 }
148 updated.push_str(&format!("\n{COMPLETION_MARKER}\n{line}\n"));
149 if let Some(parent) = rc_path.parent() {
152 fs::create_dir_all(parent)
153 .with_context(|| format!("failed to create {}", parent.display()))?;
154 }
155 fs::write(&rc_path, updated)
156 .with_context(|| format!("failed to write {}", rc_path.display()))?;
157 anstream::println!("added {shell} completion setup to {}", rc_path.display());
158 Ok(())
159}
160
161fn print_completion_hint() -> Result<()> {
164 let Some((shell, rc_path, line)) = completion_target()? else {
165 return Ok(());
166 };
167
168 let configured = fs::read_to_string(&rc_path)
169 .map(|rc| rc.contains(COMPLETION_MARKER) || rc.contains("git stk completions"))
170 .unwrap_or(false);
171 if configured {
172 return Ok(());
173 }
174
175 anstream::println!(
176 "{shell} completions are not configured; run `git stk setup`, \
177 or add this to {}:",
178 rc_path.display()
179 );
180 anstream::println!(" {line}");
181 Ok(())
182}
183
184fn completion_target() -> Result<Option<(&'static str, PathBuf, &'static str)>> {
189 if let Some(target) = posix_shell_target() {
190 return Ok(Some(target));
191 }
192 Ok(powershell_target())
193}
194
195fn posix_shell_target() -> Option<(&'static str, PathBuf, &'static str)> {
199 let shell = env::var("SHELL").unwrap_or_default();
200 let shell = shell.rsplit('/').next().unwrap_or_default();
201 let home = env::var_os("HOME").map(PathBuf::from)?;
202
203 match shell {
204 "bash" => Some((
205 "bash",
206 home.join(".bashrc"),
207 "command -v git-stk >/dev/null && source <(git stk completions bash)",
208 )),
209 "zsh" => Some((
210 "zsh",
211 home.join(".zshrc"),
212 "command -v git-stk >/dev/null && source <(git stk completions zsh)",
213 )),
214 "fish" => Some((
215 "fish",
216 home.join(".config/fish/config.fish"),
217 "command -q git-stk; and git stk completions fish | source",
218 )),
219 _ => None,
220 }
221}
222
223fn powershell_target() -> Option<(&'static str, PathBuf, &'static str)> {
226 for exe in ["pwsh", "powershell"] {
227 let Ok(output) = Command::new(exe)
228 .args(["-NoProfile", "-Command", "$PROFILE"])
229 .output()
230 else {
231 continue;
232 };
233 if !output.status.success() {
234 continue;
235 }
236 let path = String::from_utf8_lossy(&output.stdout).trim().to_owned();
237 if !path.is_empty() {
238 return Some(("PowerShell", PathBuf::from(path), POWERSHELL_LINE));
239 }
240 }
241 None
242}
243
244fn powershell_execution_policy() -> Option<String> {
247 for exe in ["pwsh", "powershell"] {
248 let Ok(output) = Command::new(exe)
249 .args(["-NoProfile", "-Command", "Get-ExecutionPolicy"])
250 .output()
251 else {
252 continue;
253 };
254 if !output.status.success() {
255 continue;
256 }
257 let policy = String::from_utf8_lossy(&output.stdout).trim().to_owned();
258 if !policy.is_empty() {
259 return Some(policy);
260 }
261 }
262 None
263}
264
265fn policy_blocks_profile(policy: &str) -> bool {
270 policy.eq_ignore_ascii_case("Restricted") || policy.eq_ignore_ascii_case("AllSigned")
271}
272
273pub fn uninstall(dry_run: bool, yes: bool) -> Result<()> {
279 let completion = match completion_target()? {
282 Some((shell, rc_path, _line)) => match fs::read_to_string(&rc_path) {
283 Ok(contents) if contents.contains(COMPLETION_MARKER) => {
284 Some((shell, rc_path, contents))
285 }
286 _ => None,
287 },
288 None => None,
289 };
290 let man_page = man_dir()
291 .ok()
292 .map(|dir| dir.join("git-stk.1"))
293 .filter(|p| p.exists());
294 let config_dir = crate::upgrade::config_dir().filter(|p| p.exists());
295
296 anstream::println!("git stk uninstall removes what setup and the installer added:");
297 let mut anything = false;
298 if let Some((shell, rc_path, _)) = &completion {
299 anstream::println!(" - {shell} completion line in {}", rc_path.display());
300 anything = true;
301 }
302 if let Some(path) = &man_page {
303 anstream::println!(" - man page {}", path.display());
304 anything = true;
305 }
306 if let Some(dir) = &config_dir {
307 anstream::println!(" - config and install receipt in {}", dir.display());
308 anything = true;
309 }
310 if !anything {
311 anstream::println!(" (nothing found - already removed, or installed another way)");
312 }
313
314 if dry_run {
315 anstream::println!("dry run: nothing was removed");
316 print_binary_note();
317 return Ok(());
318 }
319 if anything && !yes && !confirm("remove these? [y/N] ")? {
320 anstream::println!("uninstall cancelled");
321 print_binary_note();
322 return Ok(());
323 }
324
325 if let Some((shell, rc_path, contents)) = completion
326 && let Some(stripped) = strip_completion_block(&contents)
327 {
328 fs::write(&rc_path, stripped)
329 .with_context(|| format!("failed to update {}", rc_path.display()))?;
330 anstream::println!("removed {shell} completion line from {}", rc_path.display());
331 }
332 if let Some(path) = man_page {
333 fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
334 anstream::println!("removed man page {}", path.display());
335 }
336 if let Some(dir) = config_dir {
337 fs::remove_dir_all(&dir).with_context(|| format!("failed to remove {}", dir.display()))?;
338 anstream::println!("removed {}", dir.display());
339 }
340
341 print_binary_note();
342 Ok(())
343}
344
345fn print_binary_note() {
349 anstream::println!();
350 match env::current_exe() {
351 Ok(path) => {
352 anstream::println!("the git-stk binary is left in place; remove it with:");
353 if cfg!(windows) {
354 anstream::println!(" Remove-Item \"{}\"", path.display());
355 } else {
356 anstream::println!(" rm {}", path.display());
357 }
358 }
359 Err(_) => anstream::println!("remove the git-stk binary from your PATH to finish."),
360 }
361 anstream::println!(
362 "(or `cargo uninstall git-stk` / `brew uninstall git-stk` if you installed it that way)"
363 );
364 anstream::println!("per-repo stk.* config and branch metadata are left untouched.");
365}
366
367fn strip_completion_block(contents: &str) -> Option<String> {
371 let lines: Vec<&str> = contents.lines().collect();
372 let marker = lines
373 .iter()
374 .position(|line| line.trim() == COMPLETION_MARKER)?;
375
376 let removes_completion_line = lines
381 .get(marker + 1)
382 .is_some_and(|line| line.contains("git stk completions"));
383 let end = (marker + 1 + usize::from(removes_completion_line)).min(lines.len());
384 let start = marker.saturating_sub(usize::from(
386 marker > 0 && lines[marker - 1].trim().is_empty(),
387 ));
388
389 let mut kept = lines[..start].to_vec();
390 kept.extend_from_slice(&lines[end..]);
391 let mut result = kept.join("\n");
392 if !result.is_empty() && contents.ends_with('\n') {
393 result.push('\n');
394 }
395 Some(result)
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn strip_removes_the_marked_block_setup_wrote() {
404 let rc = "export PATH=/x\n\n# added by git-stk setup\ncommand -v git-stk >/dev/null && source <(git stk completions bash)\n";
407 assert_eq!(strip_completion_block(rc).unwrap(), "export PATH=/x\n");
408 }
409
410 #[test]
411 fn strip_leaves_content_after_the_block_intact() {
412 let rc = "# added by git-stk setup\ncommand -v git-stk >/dev/null && source <(git stk completions zsh)\nalias g=git\n";
414 assert_eq!(strip_completion_block(rc).unwrap(), "alias g=git\n");
415 }
416
417 #[test]
418 fn strip_keeps_a_hand_edited_line_after_an_orphaned_marker() {
419 let rc = "# added by git-stk setup\nalias g=git\n";
422 assert_eq!(strip_completion_block(rc).unwrap(), "alias g=git\n");
423 }
424
425 #[test]
426 fn strip_returns_none_without_the_marker() {
427 assert_eq!(strip_completion_block("export PATH=/x\n"), None);
428 }
429
430 #[test]
431 fn blocking_policies_stop_an_unsigned_profile() {
432 for policy in ["Restricted", "restricted", "AllSigned", "allsigned"] {
434 assert!(policy_blocks_profile(policy), "{policy} should block");
435 }
436 }
437
438 #[test]
439 fn permissive_policies_run_a_local_profile() {
440 for policy in ["RemoteSigned", "Unrestricted", "Bypass"] {
441 assert!(!policy_blocks_profile(policy), "{policy} should not block");
442 }
443 }
444}