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/// The PowerShell completion line, guarded so a removed git-stk never breaks
18/// shell startup.
19const 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        // Re-render assets that can go stale across versions. Non-interactive;
24        // run by `upgrade` via the newly installed binary. Completion wiring is
25        // left alone because the rc line re-sources from the binary on every
26        // shell start; missing wiring gets a hint instead of a prompt.
27        install_man_page()?;
28        return print_completion_hint();
29    }
30
31    install_man_page()?;
32    wire_completions(yes)?;
33    Ok(())
34}
35
36/// Render the man page into the XDG data directory, which is on the default
37/// manpath. This makes `git stk --help` work: git resolves it as `man git-stk`.
38fn 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        // Windows has no HOME; %LOCALAPPDATA% is the app-state home there.
64        .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
69/// Append a completion-sourcing line to the detected shell's rc file, once.
70fn 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    // On Windows the default execution policy (Restricted, or AllSigned) blocks
94    // PowerShell from loading *any* $PROFILE, so writing one would only surface
95    // a "not digitally signed" error on every shell start. Guide the user to
96    // relax the policy (per-user, no admin) first, rather than leaving behind a
97    // profile that can't run.
98    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    // Only prompt at a real terminal. Piped in (e.g. `curl ... | bash` running
116    // the installer), there is no one to answer, so prompting would just print
117    // a question and immediately read EOF as "no" - skip cleanly instead. Pass
118    // `--yes` (or run `git stk setup` later) to wire it up non-interactively.
119    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    // The rc file's directory may not exist yet (fish's ~/.config/fish, a
150    // never-created PowerShell profile dir).
151    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
161/// Point at `git stk setup` when the detected shell has no completion
162/// wiring yet. Used after upgrades, where prompting is not an option.
163fn 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
184/// Resolve (shell name, rc file, completion line). A POSIX shell from $SHELL
185/// wins (covers Git Bash and WSL on Windows); otherwise fall back to
186/// PowerShell. The lines guard on the binary existing so a removed git-stk
187/// never breaks shell startup.
188fn 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
195/// A bash/zsh/fish target from $SHELL, or None when $SHELL is unset/unknown
196/// or HOME is missing (e.g. native Windows). Never an error - we fall
197/// through to PowerShell.
198fn 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
223/// PowerShell's `$PROFILE` (when pwsh is on PATH). Ask the shell directly -
224/// the path differs across PowerShell 7 vs 5.1 and is often OneDrive-relocated.
225fn 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
244/// PowerShell's effective execution policy (`Get-ExecutionPolicy` resolves the
245/// per-scope stack to one value), or None when it can't be queried.
246fn 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
265/// Whether an execution policy stops an unsigned `$PROFILE` from loading.
266/// `Restricted` runs no scripts at all; `AllSigned` demands a digital signature
267/// the profile we write does not carry. Every other policy (`RemoteSigned`,
268/// `Unrestricted`, `Bypass`) runs a local profile fine.
269fn policy_blocks_profile(policy: &str) -> bool {
270    policy.eq_ignore_ascii_case("Restricted") || policy.eq_ignore_ascii_case("AllSigned")
271}
272
273/// Reverse `setup` and the installer: strip the completion line we added,
274/// delete the man page, and remove the config/receipt directory. The binary is
275/// reported (with its removal command) rather than deleted - a running exe
276/// cannot reliably unlink itself, and package-manager installs must go through
277/// their manager. Per-repo `stk.*` config and branch metadata are left alone.
278pub fn uninstall(dry_run: bool, yes: bool) -> Result<()> {
279    // The completion line, only when we can positively identify it by our own
280    // marker (a hand-added line stays - we report it instead).
281    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
345/// Tell the user how to remove the binary itself - the one thing uninstall does
346/// not do, since a running process can't reliably delete its own executable and
347/// package-manager installs must be removed through their manager.
348fn 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
367/// Drop the completion block `setup` appended - the [`COMPLETION_MARKER`], the
368/// completion line after it, and the single blank line setup put before it.
369/// `None` when there is no marker to remove.
370fn 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    // The block is "<blank>\n<marker>\n<completion line>". Only remove the line
377    // after the marker when it is actually ours - every completion line setup
378    // writes mentions `git stk completions`. If the user hand-deleted it and
379    // left the marker, the line below is their own content; keep it.
380    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    // Also drop the single blank line setup inserted before the marker.
385    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        // What `setup` produces: existing content, a blank line, the marker,
405        // the completion line.
406        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        // The full block, with the user's own lines below it, all preserved.
413        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        // The user deleted setup's completion line but left the marker; the
420        // line below is now their own and must not be removed with the marker.
421        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        // The two that reject the profile we would write, case-insensitively.
433        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}