Skip to main content

git_stk/
setup.rs

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
13/// Marker comment written above the completion line so re-runs can detect it
14/// (`#` is also a comment in PowerShell).
15const COMPLETION_MARKER: &str = "# added by git-stk setup";
16
17/// Closes the block, so `uninstall` can lift a multi-line one out exactly.
18/// Blocks written before the wrapper existed have no end marker, which is why
19/// [`strip_completion_block`] still understands the single-line shape.
20const BLOCK_END_MARKER: &str = "# end git-stk setup";
21
22/// First line of the wrapper, used to recognize it in an existing block so a
23/// re-run neither duplicates it nor claims it is missing.
24const WRAPPER_MARKER: &str = "# stk wrapper:";
25
26/// The `stk` function. Identical under bash and zsh - a POSIX function body,
27/// `case`, and `local` all behave the same in both.
28///
29/// The path is captured before the `cd` rather than `cd "$(...)"`: a navigation
30/// that fails prints nothing on stdout, and `cd ""` would then add its own
31/// `cd: null directory` on top of the error git-stk already reported.
32const 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
46/// The PowerShell completion line, guarded so a removed git-stk never breaks
47/// shell startup.
48const POWERSHELL_LINE: &str = "if (Get-Command git-stk -ErrorAction SilentlyContinue) { git stk completions powershell | Out-String | Invoke-Expression }";
49
50/// Reuse the completion registration git-stk already installed under the name
51/// `stk`, so the wrapper completes like the command it forwards to. Both forms
52/// are guarded and silenced: if the registration is missing (completions not
53/// sourced yet, an older git-stk), the wrapper still works and only completion
54/// is absent - nothing should be printed on every shell start.
55fn 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
65/// The wrapper is written as a bash/zsh function; fish needs different syntax
66/// and PowerShell a different name-resolution story entirely.
67fn wrapper_supported(shell: &str) -> bool {
68    completion_alias(shell).is_some()
69}
70
71/// The block setup appends, ending in [`BLOCK_END_MARKER`] so uninstall can
72/// remove it whole however many lines it grew to.
73fn 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
85/// Whether something else already answers to `stk`, in which case the wrapper
86/// would shadow it. Only an rc definition and a PATH executable are visible
87/// from here - a function or alias defined in another sourced file is not, so
88/// this reduces the chance of a collision rather than ruling one out.
89fn 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        // Re-render assets that can go stale across versions. Non-interactive;
116        // run by `upgrade` via the newly installed binary. Completion wiring is
117        // left alone because the rc line re-sources from the binary on every
118        // shell start; missing wiring gets a hint instead of a prompt.
119        install_man_page()?;
120        return print_completion_hint();
121    }
122
123    install_man_page()?;
124    wire_completions(yes, wrapper)?;
125    Ok(())
126}
127
128/// Render the man page into the XDG data directory, which is on the default
129/// manpath. This makes `git stk --help` work: git resolves it as `man git-stk`.
130fn 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        // Windows has no HOME; %LOCALAPPDATA% is the app-state home there.
156        .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
161/// Append a completion-sourcing line to the detected shell's rc file, once,
162/// plus the `stk` wrapper when asked for it.
163fn 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    // Defining `stk` on top of something else that answers to that name would
188    // break whatever was there. Drop the wrapper rather than win the collision.
189    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    // Adding the wrapper to a block that is already there means replacing that
215    // block, which is only safe for one we wrote: without our marker the line is
216    // the user's, and rewriting would duplicate or clobber it.
217    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    // On Windows the default execution policy (Restricted, or AllSigned) blocks
231    // PowerShell from loading *any* $PROFILE, so writing one would only surface
232    // a "not digitally signed" error on every shell start. Guide the user to
233    // relax the policy (per-user, no admin) first, rather than leaving behind a
234    // profile that can't run.
235    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    // Only prompt at a real terminal. Piped in (e.g. `curl ... | bash` running
253    // the installer), there is no one to answer, so prompting would just print
254    // a question and immediately read EOF as "no" - skip cleanly instead. Pass
255    // `--yes` (or run `git stk setup` later) to wire it up non-interactively.
256    let interactive = std::io::stdin().is_terminal();
257    // Replacing our own block rather than appending a new one - say so, since
258    // the answer decides whether an existing block gets rewritten.
259    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    // An existing block of ours is lifted out and rewritten whole, so the
293    // wrapper lands inside it and uninstall still has exactly one block to find.
294    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    // The rc file's directory may not exist yet (fish's ~/.config/fish, a
304    // never-created PowerShell profile dir).
305    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
335/// Point at `git stk setup` when the detected shell has no completion
336/// wiring yet. Used after upgrades, where prompting is not an option.
337fn 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
358/// Resolve (shell name, rc file, completion line). A POSIX shell from $SHELL
359/// wins (covers Git Bash and WSL on Windows); otherwise fall back to
360/// PowerShell. The lines guard on the binary existing so a removed git-stk
361/// never breaks shell startup.
362fn 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
369/// A bash/zsh/fish target from $SHELL, or None when $SHELL is unset/unknown
370/// or HOME is missing (e.g. native Windows). Never an error - we fall
371/// through to PowerShell.
372fn 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
397/// PowerShell's `$PROFILE` (when pwsh is on PATH). Ask the shell directly -
398/// the path differs across PowerShell 7 vs 5.1 and is often OneDrive-relocated.
399fn 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
418/// PowerShell's effective execution policy (`Get-ExecutionPolicy` resolves the
419/// per-scope stack to one value), or None when it can't be queried.
420fn 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
439/// Whether an execution policy stops an unsigned `$PROFILE` from loading.
440/// `Restricted` runs no scripts at all; `AllSigned` demands a digital signature
441/// the profile we write does not carry. Every other policy (`RemoteSigned`,
442/// `Unrestricted`, `Bypass`) runs a local profile fine.
443fn policy_blocks_profile(policy: &str) -> bool {
444    policy.eq_ignore_ascii_case("Restricted") || policy.eq_ignore_ascii_case("AllSigned")
445}
446
447/// Reverse `setup` and the installer: strip the completion line we added,
448/// delete the man page, and remove the config/receipt directory. The binary is
449/// reported (with its removal command) rather than deleted - a running exe
450/// cannot reliably unlink itself, and package-manager installs must go through
451/// their manager. Per-repo `stk.*` config and branch metadata are left alone.
452pub fn uninstall(dry_run: bool, yes: bool) -> Result<()> {
453    // The completion line, only when we can positively identify it by our own
454    // marker (a hand-added line stays - we report it instead).
455    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
519/// Tell the user how to remove the binary itself - the one thing uninstall does
520/// not do, since a running process can't reliably delete its own executable and
521/// package-manager installs must be removed through their manager.
522fn 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
541/// Drop the completion block `setup` appended - the [`COMPLETION_MARKER`], the
542/// completion line after it, and the single blank line setup put before it.
543/// `None` when there is no marker to remove.
544fn 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    // A block setup wrote ends in BLOCK_END_MARKER and comes out whole, however
551    // many lines the wrapper added. Blocks written before that marker existed
552    // are "<blank>\n<marker>\n<completion line>", so fall back to removing the
553    // one line after the marker, and only when it is actually ours - every
554    // completion line setup writes mentions `git stk completions`. If the user
555    // hand-deleted it and left the marker, the line below is their own; keep it.
556    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    // Also drop the single blank line setup inserted before the marker.
571    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        // What `setup` produces: existing content, a blank line, the marker,
591        // the completion line.
592        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        // The full block, with the user's own lines below it, all preserved.
599        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        // The user deleted setup's completion line but left the marker; the
606        // line below is now their own and must not be removed with the marker.
607        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        // The multi-line shape: the wrapper's own blank lines and braces must
619        // not end the block early, and the user's line below has to survive.
620        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        // zsh completes through compdef, not bash's `complete`.
657        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        // Our own block must not read as a collision, or a re-run would refuse
671        // to reinstall the wrapper it wrote itself.
672        assert!(stk_name_taken(&rc_block("bash", "line", true)).is_none());
673    }
674
675    #[test]
676    fn blocking_policies_stop_an_unsigned_profile() {
677        // The two that reject the profile we would write, case-insensitively.
678        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}