Skip to main content

git_stk/
setup.rs

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