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(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
61        .context("cannot locate a data directory; set HOME or XDG_DATA_HOME")?;
62    Ok(data_home.join("man/man1"))
63}
64
65/// Append a completion-sourcing line to the detected shell's rc file, once.
66fn wire_completions(yes: bool) -> Result<()> {
67    let Some((shell, rc_path, line)) = completion_target()? else {
68        anstream::println!("could not detect a supported shell");
69        anstream::println!("see the README for manual completion setup");
70        return Ok(());
71    };
72
73    let existing = match fs::read_to_string(&rc_path) {
74        Ok(contents) => contents,
75        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
76        Err(error) => {
77            return Err(error).with_context(|| format!("failed to read {}", rc_path.display()));
78        }
79    };
80
81    if existing.contains(COMPLETION_MARKER) || existing.contains("git stk completions") {
82        anstream::println!(
83            "{shell} completions already configured in {}",
84            rc_path.display()
85        );
86        return Ok(());
87    }
88
89    // Only prompt at a real terminal. Piped in (e.g. `curl ... | bash` running
90    // the installer), there is no one to answer, so prompting would just print
91    // a question and immediately read EOF as "no" - skip cleanly instead. Pass
92    // `--yes` (or run `git stk setup` later) to wire it up non-interactively.
93    let interactive = std::io::stdin().is_terminal();
94    let proceed = if yes {
95        true
96    } else if interactive {
97        confirm(&format!(
98            "append completion setup to {}? [y/N] ",
99            rc_path.display()
100        ))?
101    } else {
102        false
103    };
104    if !proceed {
105        anstream::println!(
106            "{}",
107            if interactive {
108                "skipped completion setup"
109            } else {
110                "non-interactive shell; skipped completion setup"
111            }
112        );
113        anstream::println!("to configure manually, add this to {}:", rc_path.display());
114        anstream::println!("  {line}");
115        return Ok(());
116    }
117
118    let mut updated = existing;
119    if !updated.is_empty() && !updated.ends_with('\n') {
120        updated.push('\n');
121    }
122    updated.push_str(&format!("\n{COMPLETION_MARKER}\n{line}\n"));
123    // The rc file's directory may not exist yet (fish's ~/.config/fish, a
124    // never-created PowerShell profile dir).
125    if let Some(parent) = rc_path.parent() {
126        fs::create_dir_all(parent)
127            .with_context(|| format!("failed to create {}", parent.display()))?;
128    }
129    fs::write(&rc_path, updated)
130        .with_context(|| format!("failed to write {}", rc_path.display()))?;
131    anstream::println!("added {shell} completion setup to {}", rc_path.display());
132    Ok(())
133}
134
135/// Point at `git stk setup` when the detected shell has no completion
136/// wiring yet. Used after upgrades, where prompting is not an option.
137fn print_completion_hint() -> Result<()> {
138    let Some((shell, rc_path, line)) = completion_target()? else {
139        return Ok(());
140    };
141
142    let configured = fs::read_to_string(&rc_path)
143        .map(|rc| rc.contains(COMPLETION_MARKER) || rc.contains("git stk completions"))
144        .unwrap_or(false);
145    if configured {
146        return Ok(());
147    }
148
149    anstream::println!(
150        "{shell} completions are not configured; run `git stk setup`, \
151         or add this to {}:",
152        rc_path.display()
153    );
154    anstream::println!("  {line}");
155    Ok(())
156}
157
158/// Resolve (shell name, rc file, completion line). A POSIX shell from $SHELL
159/// wins (covers Git Bash and WSL on Windows); otherwise fall back to
160/// PowerShell. The lines guard on the binary existing so a removed git-stk
161/// never breaks shell startup.
162fn completion_target() -> Result<Option<(&'static str, PathBuf, &'static str)>> {
163    if let Some(target) = posix_shell_target() {
164        return Ok(Some(target));
165    }
166    Ok(powershell_target())
167}
168
169/// A bash/zsh/fish target from $SHELL, or None when $SHELL is unset/unknown
170/// or HOME is missing (e.g. native Windows). Never an error - we fall
171/// through to PowerShell.
172fn posix_shell_target() -> Option<(&'static str, PathBuf, &'static str)> {
173    let shell = env::var("SHELL").unwrap_or_default();
174    let shell = shell.rsplit('/').next().unwrap_or_default();
175    let home = env::var_os("HOME").map(PathBuf::from)?;
176
177    match shell {
178        "bash" => Some((
179            "bash",
180            home.join(".bashrc"),
181            "command -v git-stk >/dev/null && source <(git stk completions bash)",
182        )),
183        "zsh" => Some((
184            "zsh",
185            home.join(".zshrc"),
186            "command -v git-stk >/dev/null && source <(git stk completions zsh)",
187        )),
188        "fish" => Some((
189            "fish",
190            home.join(".config/fish/config.fish"),
191            "command -q git-stk; and git stk completions fish | source",
192        )),
193        _ => None,
194    }
195}
196
197/// PowerShell's `$PROFILE` (when pwsh is on PATH). Ask the shell directly -
198/// the path differs across PowerShell 7 vs 5.1 and is often OneDrive-relocated.
199fn powershell_target() -> Option<(&'static str, PathBuf, &'static str)> {
200    for exe in ["pwsh", "powershell"] {
201        let Ok(output) = Command::new(exe)
202            .args(["-NoProfile", "-Command", "$PROFILE"])
203            .output()
204        else {
205            continue;
206        };
207        if !output.status.success() {
208            continue;
209        }
210        let path = String::from_utf8_lossy(&output.stdout).trim().to_owned();
211        if !path.is_empty() {
212            return Some(("PowerShell", PathBuf::from(path), POWERSHELL_LINE));
213        }
214    }
215    None
216}
217
218/// Reverse `setup` and the installer: strip the completion line we added,
219/// delete the man page, and remove the config/receipt directory. The binary is
220/// reported (with its removal command) rather than deleted - a running exe
221/// cannot reliably unlink itself, and package-manager installs must go through
222/// their manager. Per-repo `stk.*` config and branch metadata are left alone.
223pub fn uninstall(dry_run: bool, yes: bool) -> Result<()> {
224    // The completion line, only when we can positively identify it by our own
225    // marker (a hand-added line stays - we report it instead).
226    let completion = match completion_target()? {
227        Some((shell, rc_path, _line)) => match fs::read_to_string(&rc_path) {
228            Ok(contents) if contents.contains(COMPLETION_MARKER) => {
229                Some((shell, rc_path, contents))
230            }
231            _ => None,
232        },
233        None => None,
234    };
235    let man_page = man_dir()
236        .ok()
237        .map(|dir| dir.join("git-stk.1"))
238        .filter(|p| p.exists());
239    let config_dir = crate::upgrade::config_dir().filter(|p| p.exists());
240
241    anstream::println!("git stk uninstall removes what setup and the installer added:");
242    let mut anything = false;
243    if let Some((shell, rc_path, _)) = &completion {
244        anstream::println!("  - {shell} completion line in {}", rc_path.display());
245        anything = true;
246    }
247    if let Some(path) = &man_page {
248        anstream::println!("  - man page {}", path.display());
249        anything = true;
250    }
251    if let Some(dir) = &config_dir {
252        anstream::println!("  - config and install receipt in {}", dir.display());
253        anything = true;
254    }
255    if !anything {
256        anstream::println!("  (nothing found - already removed, or installed another way)");
257    }
258
259    if dry_run {
260        anstream::println!("dry run: nothing was removed");
261        print_binary_note();
262        return Ok(());
263    }
264    if anything && !yes && !confirm("remove these? [y/N] ")? {
265        anstream::println!("uninstall cancelled");
266        print_binary_note();
267        return Ok(());
268    }
269
270    if let Some((shell, rc_path, contents)) = completion
271        && let Some(stripped) = strip_completion_block(&contents)
272    {
273        fs::write(&rc_path, stripped)
274            .with_context(|| format!("failed to update {}", rc_path.display()))?;
275        anstream::println!("removed {shell} completion line from {}", rc_path.display());
276    }
277    if let Some(path) = man_page {
278        fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
279        anstream::println!("removed man page {}", path.display());
280    }
281    if let Some(dir) = config_dir {
282        fs::remove_dir_all(&dir).with_context(|| format!("failed to remove {}", dir.display()))?;
283        anstream::println!("removed {}", dir.display());
284    }
285
286    print_binary_note();
287    Ok(())
288}
289
290/// Tell the user how to remove the binary itself - the one thing uninstall does
291/// not do, since a running process can't reliably delete its own executable and
292/// package-manager installs must be removed through their manager.
293fn print_binary_note() {
294    anstream::println!();
295    match env::current_exe() {
296        Ok(path) => {
297            anstream::println!("the git-stk binary is left in place; remove it with:");
298            anstream::println!("  rm {}", path.display());
299        }
300        Err(_) => anstream::println!("remove the git-stk binary from your PATH to finish."),
301    }
302    anstream::println!(
303        "(or `cargo uninstall git-stk` / `brew uninstall git-stk` if you installed it that way)"
304    );
305    anstream::println!("per-repo stk.* config and branch metadata are left untouched.");
306}
307
308/// Drop the completion block `setup` appended - the [`COMPLETION_MARKER`], the
309/// completion line after it, and the single blank line setup put before it.
310/// `None` when there is no marker to remove.
311fn strip_completion_block(contents: &str) -> Option<String> {
312    let lines: Vec<&str> = contents.lines().collect();
313    let marker = lines
314        .iter()
315        .position(|line| line.trim() == COMPLETION_MARKER)?;
316    // The block is "<blank>\n<marker>\n<completion line>"; drop a preceding
317    // blank line if setup inserted one, and the completion line after.
318    let start = marker.saturating_sub(usize::from(
319        marker > 0 && lines[marker - 1].trim().is_empty(),
320    ));
321    let end = (marker + 2).min(lines.len());
322
323    let mut kept = lines[..start].to_vec();
324    kept.extend_from_slice(&lines[end..]);
325    let mut result = kept.join("\n");
326    if !result.is_empty() && contents.ends_with('\n') {
327        result.push('\n');
328    }
329    Some(result)
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn strip_removes_the_marked_block_setup_wrote() {
338        // What `setup` produces: existing content, a blank line, the marker,
339        // the completion line.
340        let rc = "export PATH=/x\n\n# added by git-stk setup\ncommand -v git-stk >/dev/null && source <(git stk completions bash)\n";
341        assert_eq!(strip_completion_block(rc).unwrap(), "export PATH=/x\n");
342    }
343
344    #[test]
345    fn strip_leaves_other_content_intact() {
346        // Lines after the block are preserved.
347        let rc = "# added by git-stk setup\ncommand -v git-stk\nalias g=git\n";
348        assert_eq!(strip_completion_block(rc).unwrap(), "alias g=git\n");
349    }
350
351    #[test]
352    fn strip_returns_none_without_the_marker() {
353        assert_eq!(strip_completion_block("export PATH=/x\n"), None);
354    }
355}