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 BLOCK_END_MARKER: &str = "# end git-stk setup";
21
22const WRAPPER_MARKER: &str = "# stk wrapper:";
25
26const WRAPPER_BODY: &str = r#"# stk wrapper: up/down/top/bottom cd into the worktree holding the branch.
33# A process cannot change its parent shell's directory, so git-stk prints the
34# destination and this moves you. Every other command falls through to git stk.
35stk() {
36 case "$1" in
37 up|down|top|bottom)
38 local dest
39 dest=$(git stk "$@" --from-path) || return
40 [ -n "$dest" ] && cd "$dest"
41 ;;
42 *) git stk "$@" ;;
43 esac
44}"#;
45
46const POWERSHELL_LINE: &str = "if (Get-Command git-stk -ErrorAction SilentlyContinue) { git stk completions powershell | Out-String | Invoke-Expression }";
49
50fn completion_alias(shell: &str) -> Option<&'static str> {
56 match shell {
57 "bash" => Some(
58 r#"complete -p git-stk >/dev/null 2>&1 && eval "$(complete -p git-stk | sed 's/ git-stk$/ stk/')""#,
59 ),
60 "zsh" => Some("(( $+functions[compdef] )) && compdef stk=git-stk 2>/dev/null"),
61 _ => None,
62 }
63}
64
65fn wrapper_supported(shell: &str) -> bool {
68 completion_alias(shell).is_some()
69}
70
71fn rc_block(shell: &str, line: &str, wrapper: bool) -> String {
74 let mut block = format!("{COMPLETION_MARKER}\n{line}\n");
75 if wrapper {
76 block.push_str(&format!("\n{WRAPPER_BODY}\n"));
77 if let Some(alias) = completion_alias(shell) {
78 block.push_str(&format!("{alias}\n"));
79 }
80 }
81 block.push_str(&format!("{BLOCK_END_MARKER}\n"));
82 block
83}
84
85fn stk_name_taken(rc: &str) -> Option<String> {
90 for line in rc.lines() {
91 let trimmed = line.trim();
92 if trimmed.starts_with(WRAPPER_MARKER) || trimmed.starts_with("stk()") {
93 continue;
94 }
95 if trimmed.starts_with("alias stk=") || trimmed.starts_with("function stk") {
96 return Some(format!("your rc file already defines stk (`{trimmed}`)"));
97 }
98 }
99
100 let path = env::var_os("PATH")?;
101 for dir in env::split_paths(&path) {
102 let candidate = dir.join("stk");
103 if candidate.is_file() {
104 return Some(format!(
105 "an stk executable already exists at {}",
106 candidate.display()
107 ));
108 }
109 }
110 None
111}
112
113pub fn setup(yes: bool, refresh: bool, wrapper: bool) -> Result<()> {
114 if refresh {
115 install_man_page()?;
120 return print_completion_hint();
121 }
122
123 install_man_page()?;
124 wire_completions(yes, wrapper)?;
125 Ok(())
126}
127
128fn install_man_page() -> Result<()> {
131 if cfg!(windows) {
132 return Ok(());
133 }
134
135 let dir = man_dir()?;
136 fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
137
138 let mut buffer = Vec::new();
139 clap_mangen::Man::new(Cli::command())
140 .render(&mut buffer)
141 .context("failed to render man page")?;
142
143 let path = dir.join("git-stk.1");
144 fs::write(&path, buffer).with_context(|| format!("failed to write {}", path.display()))?;
145 anstream::println!("installed man page to {}", path.display());
146 Ok(())
147}
148
149fn man_dir() -> Result<PathBuf> {
150 let data_home = env::var_os("XDG_DATA_HOME")
151 .map(PathBuf::from)
152 .or_else(|| {
153 env::var_os("HOME").map(|home| PathBuf::from(home).join(".local").join("share"))
154 })
155 .or_else(|| env::var_os("LOCALAPPDATA").map(PathBuf::from))
157 .context("cannot locate a data directory; set HOME, XDG_DATA_HOME, or LOCALAPPDATA")?;
158 Ok(data_home.join("man").join("man1"))
159}
160
161fn wire_completions(yes: bool, wrapper: bool) -> Result<()> {
164 let Some((shell, rc_path, line)) = completion_target()? else {
165 anstream::println!("could not detect a supported shell");
166 anstream::println!("see the README for manual completion setup");
167 return Ok(());
168 };
169
170 if wrapper && !wrapper_supported(shell) {
171 anstream::println!(
172 "the stk wrapper is a bash/zsh shell function; {shell} needs different \
173 syntax, so it was not added"
174 );
175 anstream::println!("see the Worktrees section of the README for a starting point");
176 }
177 let mut wrapper = wrapper && wrapper_supported(shell);
178
179 let existing = match fs::read_to_string(&rc_path) {
180 Ok(contents) => contents,
181 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
182 Err(error) => {
183 return Err(error).with_context(|| format!("failed to read {}", rc_path.display()));
184 }
185 };
186
187 if wrapper && let Some(clash) = stk_name_taken(&existing) {
190 anstream::println!("skipped the stk wrapper: {clash}");
191 wrapper = false;
192 }
193
194 let configured =
195 existing.contains(COMPLETION_MARKER) || existing.contains("git stk completions");
196 let has_wrapper = existing.contains(WRAPPER_MARKER);
197 if configured && (!wrapper || has_wrapper) {
198 anstream::println!(
199 "{shell} completions already configured in {}",
200 rc_path.display()
201 );
202 if wrapper_supported(shell) && !has_wrapper {
203 anstream::println!(
204 "{}",
205 crate::style::dim(
206 "the stk wrapper (up/down cd into another worktree) is not installed; \
207 add it with `git stk setup --wrapper`"
208 )
209 );
210 }
211 return Ok(());
212 }
213
214 if configured && !existing.contains(COMPLETION_MARKER) {
218 anstream::println!(
219 "completion setup in {} was added by hand, so the wrapper was not \
220 merged into it",
221 rc_path.display()
222 );
223 anstream::println!("add this yourself:");
224 for wrapper_line in rc_block(shell, line, true).lines().skip(2) {
225 anstream::println!(" {wrapper_line}");
226 }
227 return Ok(());
228 }
229
230 if shell == "PowerShell"
236 && let Some(policy) = powershell_execution_policy()
237 && policy_blocks_profile(&policy)
238 {
239 anstream::println!(
240 "PowerShell's execution policy ({policy}) blocks profile scripts, so \
241 completions can't be enabled without breaking shell startup."
242 );
243 anstream::println!(
244 "allow your profile to run (per-user, no admin needed), then re-run `git stk setup`:"
245 );
246 anstream::println!(" Set-ExecutionPolicy -Scope CurrentUser RemoteSigned");
247 anstream::println!("or add this line to {} yourself:", rc_path.display());
248 anstream::println!(" {line}");
249 return Ok(());
250 }
251
252 let interactive = std::io::stdin().is_terminal();
257 let question = if configured {
260 format!("add the stk wrapper to {}? [y/N] ", rc_path.display())
261 } else if wrapper {
262 format!(
263 "append completion setup and the stk wrapper to {}? [y/N] ",
264 rc_path.display()
265 )
266 } else {
267 format!("append completion setup to {}? [y/N] ", rc_path.display())
268 };
269 let proceed = if yes {
270 true
271 } else if interactive {
272 confirm(&question)?
273 } else {
274 false
275 };
276 if !proceed {
277 anstream::println!(
278 "{}",
279 if interactive {
280 "skipped completion setup"
281 } else {
282 "non-interactive shell; skipped completion setup"
283 }
284 );
285 anstream::println!("to configure manually, add this to {}:", rc_path.display());
286 for block_line in rc_block(shell, line, wrapper).lines().skip(1) {
287 anstream::println!(" {block_line}");
288 }
289 return Ok(());
290 }
291
292 let mut updated = if configured {
295 strip_completion_block(&existing).unwrap_or(existing)
296 } else {
297 existing
298 };
299 if !updated.is_empty() && !updated.ends_with('\n') {
300 updated.push('\n');
301 }
302 updated.push_str(&format!("\n{}", rc_block(shell, line, wrapper)));
303 if let Some(parent) = rc_path.parent() {
306 fs::create_dir_all(parent)
307 .with_context(|| format!("failed to create {}", parent.display()))?;
308 }
309 fs::write(&rc_path, updated)
310 .with_context(|| format!("failed to write {}", rc_path.display()))?;
311 if wrapper {
312 anstream::println!(
313 "added {shell} completion setup and the stk wrapper to {}",
314 rc_path.display()
315 );
316 anstream::println!(
317 "{}",
318 crate::style::dim("start a new shell, then `stk up` follows a branch across worktrees")
319 );
320 } else {
321 anstream::println!("added {shell} completion setup to {}", rc_path.display());
322 if wrapper_supported(shell) {
323 anstream::println!(
324 "{}",
325 crate::style::dim(
326 "`git stk setup --wrapper` also defines an stk function whose up/down \
327 cd into another worktree"
328 )
329 );
330 }
331 }
332 Ok(())
333}
334
335fn print_completion_hint() -> Result<()> {
338 let Some((shell, rc_path, line)) = completion_target()? else {
339 return Ok(());
340 };
341
342 let configured = fs::read_to_string(&rc_path)
343 .map(|rc| rc.contains(COMPLETION_MARKER) || rc.contains("git stk completions"))
344 .unwrap_or(false);
345 if configured {
346 return Ok(());
347 }
348
349 anstream::println!(
350 "{shell} completions are not configured; run `git stk setup`, \
351 or add this to {}:",
352 rc_path.display()
353 );
354 anstream::println!(" {line}");
355 Ok(())
356}
357
358fn completion_target() -> Result<Option<(&'static str, PathBuf, &'static str)>> {
363 if let Some(target) = posix_shell_target() {
364 return Ok(Some(target));
365 }
366 Ok(powershell_target())
367}
368
369fn posix_shell_target() -> Option<(&'static str, PathBuf, &'static str)> {
373 let shell = env::var("SHELL").unwrap_or_default();
374 let shell = shell.rsplit('/').next().unwrap_or_default();
375 let home = env::var_os("HOME").map(PathBuf::from)?;
376
377 match shell {
378 "bash" => Some((
379 "bash",
380 home.join(".bashrc"),
381 "command -v git-stk >/dev/null && source <(git stk completions bash)",
382 )),
383 "zsh" => Some((
384 "zsh",
385 home.join(".zshrc"),
386 "command -v git-stk >/dev/null && source <(git stk completions zsh)",
387 )),
388 "fish" => Some((
389 "fish",
390 home.join(".config/fish/config.fish"),
391 "command -q git-stk; and git stk completions fish | source",
392 )),
393 _ => None,
394 }
395}
396
397fn powershell_target() -> Option<(&'static str, PathBuf, &'static str)> {
400 for exe in ["pwsh", "powershell"] {
401 let Ok(output) = Command::new(exe)
402 .args(["-NoProfile", "-Command", "$PROFILE"])
403 .output()
404 else {
405 continue;
406 };
407 if !output.status.success() {
408 continue;
409 }
410 let path = String::from_utf8_lossy(&output.stdout).trim().to_owned();
411 if !path.is_empty() {
412 return Some(("PowerShell", PathBuf::from(path), POWERSHELL_LINE));
413 }
414 }
415 None
416}
417
418fn powershell_execution_policy() -> Option<String> {
421 for exe in ["pwsh", "powershell"] {
422 let Ok(output) = Command::new(exe)
423 .args(["-NoProfile", "-Command", "Get-ExecutionPolicy"])
424 .output()
425 else {
426 continue;
427 };
428 if !output.status.success() {
429 continue;
430 }
431 let policy = String::from_utf8_lossy(&output.stdout).trim().to_owned();
432 if !policy.is_empty() {
433 return Some(policy);
434 }
435 }
436 None
437}
438
439fn policy_blocks_profile(policy: &str) -> bool {
444 policy.eq_ignore_ascii_case("Restricted") || policy.eq_ignore_ascii_case("AllSigned")
445}
446
447pub fn uninstall(dry_run: bool, yes: bool) -> Result<()> {
453 let completion = match completion_target()? {
456 Some((shell, rc_path, _line)) => match fs::read_to_string(&rc_path) {
457 Ok(contents) if contents.contains(COMPLETION_MARKER) => {
458 Some((shell, rc_path, contents))
459 }
460 _ => None,
461 },
462 None => None,
463 };
464 let man_page = man_dir()
465 .ok()
466 .map(|dir| dir.join("git-stk.1"))
467 .filter(|p| p.exists());
468 let config_dir = crate::upgrade::config_dir().filter(|p| p.exists());
469
470 anstream::println!("git stk uninstall removes what setup and the installer added:");
471 let mut anything = false;
472 if let Some((shell, rc_path, _)) = &completion {
473 anstream::println!(" - {shell} completion line in {}", rc_path.display());
474 anything = true;
475 }
476 if let Some(path) = &man_page {
477 anstream::println!(" - man page {}", path.display());
478 anything = true;
479 }
480 if let Some(dir) = &config_dir {
481 anstream::println!(" - config and install receipt in {}", dir.display());
482 anything = true;
483 }
484 if !anything {
485 anstream::println!(" (nothing found - already removed, or installed another way)");
486 }
487
488 if dry_run {
489 anstream::println!("dry run: nothing was removed");
490 print_binary_note();
491 return Ok(());
492 }
493 if anything && !yes && !confirm("remove these? [y/N] ")? {
494 anstream::println!("uninstall cancelled");
495 print_binary_note();
496 return Ok(());
497 }
498
499 if let Some((shell, rc_path, contents)) = completion
500 && let Some(stripped) = strip_completion_block(&contents)
501 {
502 fs::write(&rc_path, stripped)
503 .with_context(|| format!("failed to update {}", rc_path.display()))?;
504 anstream::println!("removed {shell} completion line from {}", rc_path.display());
505 }
506 if let Some(path) = man_page {
507 fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
508 anstream::println!("removed man page {}", path.display());
509 }
510 if let Some(dir) = config_dir {
511 fs::remove_dir_all(&dir).with_context(|| format!("failed to remove {}", dir.display()))?;
512 anstream::println!("removed {}", dir.display());
513 }
514
515 print_binary_note();
516 Ok(())
517}
518
519fn print_binary_note() {
523 anstream::println!();
524 match env::current_exe() {
525 Ok(path) => {
526 anstream::println!("the git-stk binary is left in place; remove it with:");
527 if cfg!(windows) {
528 anstream::println!(" Remove-Item \"{}\"", path.display());
529 } else {
530 anstream::println!(" rm {}", path.display());
531 }
532 }
533 Err(_) => anstream::println!("remove the git-stk binary from your PATH to finish."),
534 }
535 anstream::println!(
536 "(or `cargo uninstall git-stk` / `brew uninstall git-stk` if you installed it that way)"
537 );
538 anstream::println!("per-repo stk.* config and branch metadata are left untouched.");
539}
540
541fn strip_completion_block(contents: &str) -> Option<String> {
545 let lines: Vec<&str> = contents.lines().collect();
546 let marker = lines
547 .iter()
548 .position(|line| line.trim() == COMPLETION_MARKER)?;
549
550 let end = match lines
557 .iter()
558 .skip(marker + 1)
559 .position(|line| line.trim() == BLOCK_END_MARKER)
560 {
561 Some(offset) => marker + offset + 2,
562 None => {
563 let removes_completion_line = lines
564 .get(marker + 1)
565 .is_some_and(|line| line.contains("git stk completions"));
566 marker + 1 + usize::from(removes_completion_line)
567 }
568 }
569 .min(lines.len());
570 let start = marker.saturating_sub(usize::from(
572 marker > 0 && lines[marker - 1].trim().is_empty(),
573 ));
574
575 let mut kept = lines[..start].to_vec();
576 kept.extend_from_slice(&lines[end..]);
577 let mut result = kept.join("\n");
578 if !result.is_empty() && contents.ends_with('\n') {
579 result.push('\n');
580 }
581 Some(result)
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn strip_removes_the_marked_block_setup_wrote() {
590 let rc = "export PATH=/x\n\n# added by git-stk setup\ncommand -v git-stk >/dev/null && source <(git stk completions bash)\n";
593 assert_eq!(strip_completion_block(rc).unwrap(), "export PATH=/x\n");
594 }
595
596 #[test]
597 fn strip_leaves_content_after_the_block_intact() {
598 let rc = "# added by git-stk setup\ncommand -v git-stk >/dev/null && source <(git stk completions zsh)\nalias g=git\n";
600 assert_eq!(strip_completion_block(rc).unwrap(), "alias g=git\n");
601 }
602
603 #[test]
604 fn strip_keeps_a_hand_edited_line_after_an_orphaned_marker() {
605 let rc = "# added by git-stk setup\nalias g=git\n";
608 assert_eq!(strip_completion_block(rc).unwrap(), "alias g=git\n");
609 }
610
611 #[test]
612 fn strip_returns_none_without_the_marker() {
613 assert_eq!(strip_completion_block("export PATH=/x\n"), None);
614 }
615
616 #[test]
617 fn strip_removes_a_wrapper_block_whole() {
618 let rc = format!(
621 "export PATH=/x\n\n{}\nalias g=git\n",
622 rc_block(
623 "bash",
624 "command -v git-stk >/dev/null && source <(git stk completions bash)",
625 true
626 )
627 .trim_end()
628 );
629 assert_eq!(
630 strip_completion_block(&rc).unwrap(),
631 "export PATH=/x\nalias g=git\n"
632 );
633 }
634
635 #[test]
636 fn strip_removes_a_wrapperless_block_with_an_end_marker() {
637 let rc = format!(
638 "{}\nalias g=git\n",
639 rc_block(
640 "zsh",
641 "command -v git-stk >/dev/null && source <(git stk completions zsh)",
642 false
643 )
644 .trim_end()
645 );
646 assert_eq!(strip_completion_block(&rc).unwrap(), "alias g=git\n");
647 }
648
649 #[test]
650 fn a_wrapper_block_carries_the_function_and_the_completion_alias() {
651 let block = rc_block("bash", "line", true);
652 assert!(block.contains("stk() {"), "{block}");
653 assert!(block.contains(WRAPPER_MARKER), "{block}");
654 assert!(block.contains("complete -p git-stk"), "{block}");
655 assert!(block.trim_end().ends_with(BLOCK_END_MARKER), "{block}");
656 assert!(rc_block("zsh", "line", true).contains("compdef stk=git-stk"));
658 }
659
660 #[test]
661 fn the_wrapper_is_bash_and_zsh_only() {
662 assert!(wrapper_supported("bash") && wrapper_supported("zsh"));
663 assert!(!wrapper_supported("fish") && !wrapper_supported("PowerShell"));
664 }
665
666 #[test]
667 fn an_existing_stk_definition_is_detected_but_our_own_is_not() {
668 assert!(stk_name_taken("alias stk=git-stk\n").is_some());
669 assert!(stk_name_taken("function stk { }\n").is_some());
670 assert!(stk_name_taken(&rc_block("bash", "line", true)).is_none());
673 }
674
675 #[test]
676 fn blocking_policies_stop_an_unsigned_profile() {
677 for policy in ["Restricted", "restricted", "AllSigned", "allsigned"] {
679 assert!(policy_blocks_profile(policy), "{policy} should block");
680 }
681 }
682
683 #[test]
684 fn permissive_policies_run_a_local_profile() {
685 for policy in ["RemoteSigned", "Unrestricted", "Bypass"] {
686 assert!(!policy_blocks_profile(policy), "{policy} should not block");
687 }
688 }
689}