use std::env;
use std::fs;
use std::io::IsTerminal;
use std::path::PathBuf;
use std::process::Command;
use anyhow::{Context, Result};
use clap::CommandFactory;
use crate::cli::Cli;
use crate::prompt::confirm;
const COMPLETION_MARKER: &str = "# added by git-stk setup";
const BLOCK_END_MARKER: &str = "# end git-stk setup";
const WRAPPER_MARKER: &str = "# stk wrapper:";
const WRAPPER_BODY: &str = r#"# stk wrapper: up/down/top/bottom cd into the worktree holding the branch.
# A process cannot change its parent shell's directory, so git-stk prints the
# destination and this moves you. Every other command falls through to git stk.
stk() {
case "$1" in
up|down|top|bottom)
local dest
dest=$(git stk "$@" --from-path) || return
[ -n "$dest" ] && cd "$dest"
;;
*) git stk "$@" ;;
esac
}"#;
const POWERSHELL_LINE: &str = "if (Get-Command git-stk -ErrorAction SilentlyContinue) { git stk completions powershell | Out-String | Invoke-Expression }";
fn completion_alias(shell: &str) -> Option<&'static str> {
match shell {
"bash" => Some(
r#"complete -p git-stk >/dev/null 2>&1 && eval "$(complete -p git-stk | sed 's/ git-stk$/ stk/')""#,
),
"zsh" => Some("(( $+functions[compdef] )) && compdef stk=git-stk 2>/dev/null"),
_ => None,
}
}
fn wrapper_supported(shell: &str) -> bool {
completion_alias(shell).is_some()
}
fn rc_block(shell: &str, line: &str, wrapper: bool) -> String {
let mut block = format!("{COMPLETION_MARKER}\n{line}\n");
if wrapper {
block.push_str(&format!("\n{WRAPPER_BODY}\n"));
if let Some(alias) = completion_alias(shell) {
block.push_str(&format!("{alias}\n"));
}
}
block.push_str(&format!("{BLOCK_END_MARKER}\n"));
block
}
fn stk_name_taken(rc: &str) -> Option<String> {
for line in rc.lines() {
let trimmed = line.trim();
if trimmed.starts_with(WRAPPER_MARKER) || trimmed.starts_with("stk()") {
continue;
}
if trimmed.starts_with("alias stk=") || trimmed.starts_with("function stk") {
return Some(format!("your rc file already defines stk (`{trimmed}`)"));
}
}
let path = env::var_os("PATH")?;
for dir in env::split_paths(&path) {
let candidate = dir.join("stk");
if candidate.is_file() {
return Some(format!(
"an stk executable already exists at {}",
candidate.display()
));
}
}
None
}
pub fn setup(yes: bool, refresh: bool, wrapper: bool) -> Result<()> {
if refresh {
install_man_page()?;
return print_completion_hint();
}
install_man_page()?;
wire_completions(yes, wrapper)?;
Ok(())
}
fn install_man_page() -> Result<()> {
if cfg!(windows) {
return Ok(());
}
let dir = man_dir()?;
fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
let mut buffer = Vec::new();
clap_mangen::Man::new(Cli::command())
.render(&mut buffer)
.context("failed to render man page")?;
let path = dir.join("git-stk.1");
fs::write(&path, buffer).with_context(|| format!("failed to write {}", path.display()))?;
anstream::println!("installed man page to {}", path.display());
Ok(())
}
fn man_dir() -> Result<PathBuf> {
let data_home = env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| {
env::var_os("HOME").map(|home| PathBuf::from(home).join(".local").join("share"))
})
.or_else(|| env::var_os("LOCALAPPDATA").map(PathBuf::from))
.context("cannot locate a data directory; set HOME, XDG_DATA_HOME, or LOCALAPPDATA")?;
Ok(data_home.join("man").join("man1"))
}
fn wire_completions(yes: bool, wrapper: bool) -> Result<()> {
let Some((shell, rc_path, line)) = completion_target()? else {
anstream::println!("could not detect a supported shell");
anstream::println!("see the README for manual completion setup");
return Ok(());
};
if wrapper && !wrapper_supported(shell) {
anstream::println!(
"the stk wrapper is a bash/zsh shell function; {shell} needs different \
syntax, so it was not added"
);
anstream::println!("see the Worktrees section of the README for a starting point");
}
let mut wrapper = wrapper && wrapper_supported(shell);
let existing = match fs::read_to_string(&rc_path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => {
return Err(error).with_context(|| format!("failed to read {}", rc_path.display()));
}
};
if wrapper && let Some(clash) = stk_name_taken(&existing) {
anstream::println!("skipped the stk wrapper: {clash}");
wrapper = false;
}
let configured =
existing.contains(COMPLETION_MARKER) || existing.contains("git stk completions");
let has_wrapper = existing.contains(WRAPPER_MARKER);
if configured && (!wrapper || has_wrapper) {
anstream::println!(
"{shell} completions already configured in {}",
rc_path.display()
);
if wrapper_supported(shell) && !has_wrapper {
anstream::println!(
"{}",
crate::style::dim(
"the stk wrapper (up/down cd into another worktree) is not installed; \
add it with `git stk setup --wrapper`"
)
);
}
return Ok(());
}
if configured && !existing.contains(COMPLETION_MARKER) {
anstream::println!(
"completion setup in {} was added by hand, so the wrapper was not \
merged into it",
rc_path.display()
);
anstream::println!("add this yourself:");
for wrapper_line in rc_block(shell, line, true).lines().skip(2) {
anstream::println!(" {wrapper_line}");
}
return Ok(());
}
if shell == "PowerShell"
&& let Some(policy) = powershell_execution_policy()
&& policy_blocks_profile(&policy)
{
anstream::println!(
"PowerShell's execution policy ({policy}) blocks profile scripts, so \
completions can't be enabled without breaking shell startup."
);
anstream::println!(
"allow your profile to run (per-user, no admin needed), then re-run `git stk setup`:"
);
anstream::println!(" Set-ExecutionPolicy -Scope CurrentUser RemoteSigned");
anstream::println!("or add this line to {} yourself:", rc_path.display());
anstream::println!(" {line}");
return Ok(());
}
let interactive = std::io::stdin().is_terminal();
let question = if configured {
format!("add the stk wrapper to {}? [y/N] ", rc_path.display())
} else if wrapper {
format!(
"append completion setup and the stk wrapper to {}? [y/N] ",
rc_path.display()
)
} else {
format!("append completion setup to {}? [y/N] ", rc_path.display())
};
let proceed = if yes {
true
} else if interactive {
confirm(&question)?
} else {
false
};
if !proceed {
anstream::println!(
"{}",
if interactive {
"skipped completion setup"
} else {
"non-interactive shell; skipped completion setup"
}
);
anstream::println!("to configure manually, add this to {}:", rc_path.display());
for block_line in rc_block(shell, line, wrapper).lines().skip(1) {
anstream::println!(" {block_line}");
}
return Ok(());
}
let mut updated = if configured {
strip_completion_block(&existing).unwrap_or(existing)
} else {
existing
};
if !updated.is_empty() && !updated.ends_with('\n') {
updated.push('\n');
}
updated.push_str(&format!("\n{}", rc_block(shell, line, wrapper)));
if let Some(parent) = rc_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(&rc_path, updated)
.with_context(|| format!("failed to write {}", rc_path.display()))?;
if wrapper {
anstream::println!(
"added {shell} completion setup and the stk wrapper to {}",
rc_path.display()
);
anstream::println!(
"{}",
crate::style::dim("start a new shell, then `stk up` follows a branch across worktrees")
);
} else {
anstream::println!("added {shell} completion setup to {}", rc_path.display());
if wrapper_supported(shell) {
anstream::println!(
"{}",
crate::style::dim(
"`git stk setup --wrapper` also defines an stk function whose up/down \
cd into another worktree"
)
);
}
}
Ok(())
}
fn print_completion_hint() -> Result<()> {
let Some((shell, rc_path, line)) = completion_target()? else {
return Ok(());
};
let configured = fs::read_to_string(&rc_path)
.map(|rc| rc.contains(COMPLETION_MARKER) || rc.contains("git stk completions"))
.unwrap_or(false);
if configured {
return Ok(());
}
anstream::println!(
"{shell} completions are not configured; run `git stk setup`, \
or add this to {}:",
rc_path.display()
);
anstream::println!(" {line}");
Ok(())
}
fn completion_target() -> Result<Option<(&'static str, PathBuf, &'static str)>> {
if let Some(target) = posix_shell_target() {
return Ok(Some(target));
}
Ok(powershell_target())
}
fn posix_shell_target() -> Option<(&'static str, PathBuf, &'static str)> {
let shell = env::var("SHELL").unwrap_or_default();
let shell = shell.rsplit('/').next().unwrap_or_default();
let home = env::var_os("HOME").map(PathBuf::from)?;
match shell {
"bash" => Some((
"bash",
home.join(".bashrc"),
"command -v git-stk >/dev/null && source <(git stk completions bash)",
)),
"zsh" => Some((
"zsh",
home.join(".zshrc"),
"command -v git-stk >/dev/null && source <(git stk completions zsh)",
)),
"fish" => Some((
"fish",
home.join(".config/fish/config.fish"),
"command -q git-stk; and git stk completions fish | source",
)),
_ => None,
}
}
fn powershell_target() -> Option<(&'static str, PathBuf, &'static str)> {
for exe in ["pwsh", "powershell"] {
let Ok(output) = Command::new(exe)
.args(["-NoProfile", "-Command", "$PROFILE"])
.output()
else {
continue;
};
if !output.status.success() {
continue;
}
let path = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if !path.is_empty() {
return Some(("PowerShell", PathBuf::from(path), POWERSHELL_LINE));
}
}
None
}
fn powershell_execution_policy() -> Option<String> {
for exe in ["pwsh", "powershell"] {
let Ok(output) = Command::new(exe)
.args(["-NoProfile", "-Command", "Get-ExecutionPolicy"])
.output()
else {
continue;
};
if !output.status.success() {
continue;
}
let policy = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if !policy.is_empty() {
return Some(policy);
}
}
None
}
fn policy_blocks_profile(policy: &str) -> bool {
policy.eq_ignore_ascii_case("Restricted") || policy.eq_ignore_ascii_case("AllSigned")
}
pub fn uninstall(dry_run: bool, yes: bool) -> Result<()> {
let completion = match completion_target()? {
Some((shell, rc_path, _line)) => match fs::read_to_string(&rc_path) {
Ok(contents) if contents.contains(COMPLETION_MARKER) => {
Some((shell, rc_path, contents))
}
_ => None,
},
None => None,
};
let man_page = man_dir()
.ok()
.map(|dir| dir.join("git-stk.1"))
.filter(|p| p.exists());
let config_dir = crate::upgrade::config_dir().filter(|p| p.exists());
anstream::println!("git stk uninstall removes what setup and the installer added:");
let mut anything = false;
if let Some((shell, rc_path, _)) = &completion {
anstream::println!(" - {shell} completion line in {}", rc_path.display());
anything = true;
}
if let Some(path) = &man_page {
anstream::println!(" - man page {}", path.display());
anything = true;
}
if let Some(dir) = &config_dir {
anstream::println!(" - config and install receipt in {}", dir.display());
anything = true;
}
if !anything {
anstream::println!(" (nothing found - already removed, or installed another way)");
}
if dry_run {
anstream::println!("dry run: nothing was removed");
print_binary_note();
return Ok(());
}
if anything && !yes && !confirm("remove these? [y/N] ")? {
anstream::println!("uninstall cancelled");
print_binary_note();
return Ok(());
}
if let Some((shell, rc_path, contents)) = completion
&& let Some(stripped) = strip_completion_block(&contents)
{
fs::write(&rc_path, stripped)
.with_context(|| format!("failed to update {}", rc_path.display()))?;
anstream::println!("removed {shell} completion line from {}", rc_path.display());
}
if let Some(path) = man_page {
fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
anstream::println!("removed man page {}", path.display());
}
if let Some(dir) = config_dir {
fs::remove_dir_all(&dir).with_context(|| format!("failed to remove {}", dir.display()))?;
anstream::println!("removed {}", dir.display());
}
print_binary_note();
Ok(())
}
fn print_binary_note() {
anstream::println!();
match env::current_exe() {
Ok(path) => {
anstream::println!("the git-stk binary is left in place; remove it with:");
if cfg!(windows) {
anstream::println!(" Remove-Item \"{}\"", path.display());
} else {
anstream::println!(" rm {}", path.display());
}
}
Err(_) => anstream::println!("remove the git-stk binary from your PATH to finish."),
}
anstream::println!(
"(or `cargo uninstall git-stk` / `brew uninstall git-stk` if you installed it that way)"
);
anstream::println!("per-repo stk.* config and branch metadata are left untouched.");
}
fn strip_completion_block(contents: &str) -> Option<String> {
let lines: Vec<&str> = contents.lines().collect();
let marker = lines
.iter()
.position(|line| line.trim() == COMPLETION_MARKER)?;
let end = match lines
.iter()
.skip(marker + 1)
.position(|line| line.trim() == BLOCK_END_MARKER)
{
Some(offset) => marker + offset + 2,
None => {
let removes_completion_line = lines
.get(marker + 1)
.is_some_and(|line| line.contains("git stk completions"));
marker + 1 + usize::from(removes_completion_line)
}
}
.min(lines.len());
let start = marker.saturating_sub(usize::from(
marker > 0 && lines[marker - 1].trim().is_empty(),
));
let mut kept = lines[..start].to_vec();
kept.extend_from_slice(&lines[end..]);
let mut result = kept.join("\n");
if !result.is_empty() && contents.ends_with('\n') {
result.push('\n');
}
Some(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_removes_the_marked_block_setup_wrote() {
let rc = "export PATH=/x\n\n# added by git-stk setup\ncommand -v git-stk >/dev/null && source <(git stk completions bash)\n";
assert_eq!(strip_completion_block(rc).unwrap(), "export PATH=/x\n");
}
#[test]
fn strip_leaves_content_after_the_block_intact() {
let rc = "# added by git-stk setup\ncommand -v git-stk >/dev/null && source <(git stk completions zsh)\nalias g=git\n";
assert_eq!(strip_completion_block(rc).unwrap(), "alias g=git\n");
}
#[test]
fn strip_keeps_a_hand_edited_line_after_an_orphaned_marker() {
let rc = "# added by git-stk setup\nalias g=git\n";
assert_eq!(strip_completion_block(rc).unwrap(), "alias g=git\n");
}
#[test]
fn strip_returns_none_without_the_marker() {
assert_eq!(strip_completion_block("export PATH=/x\n"), None);
}
#[test]
fn strip_removes_a_wrapper_block_whole() {
let rc = format!(
"export PATH=/x\n\n{}\nalias g=git\n",
rc_block(
"bash",
"command -v git-stk >/dev/null && source <(git stk completions bash)",
true
)
.trim_end()
);
assert_eq!(
strip_completion_block(&rc).unwrap(),
"export PATH=/x\nalias g=git\n"
);
}
#[test]
fn strip_removes_a_wrapperless_block_with_an_end_marker() {
let rc = format!(
"{}\nalias g=git\n",
rc_block(
"zsh",
"command -v git-stk >/dev/null && source <(git stk completions zsh)",
false
)
.trim_end()
);
assert_eq!(strip_completion_block(&rc).unwrap(), "alias g=git\n");
}
#[test]
fn a_wrapper_block_carries_the_function_and_the_completion_alias() {
let block = rc_block("bash", "line", true);
assert!(block.contains("stk() {"), "{block}");
assert!(block.contains(WRAPPER_MARKER), "{block}");
assert!(block.contains("complete -p git-stk"), "{block}");
assert!(block.trim_end().ends_with(BLOCK_END_MARKER), "{block}");
assert!(rc_block("zsh", "line", true).contains("compdef stk=git-stk"));
}
#[test]
fn the_wrapper_is_bash_and_zsh_only() {
assert!(wrapper_supported("bash") && wrapper_supported("zsh"));
assert!(!wrapper_supported("fish") && !wrapper_supported("PowerShell"));
}
#[test]
fn an_existing_stk_definition_is_detected_but_our_own_is_not() {
assert!(stk_name_taken("alias stk=git-stk\n").is_some());
assert!(stk_name_taken("function stk { }\n").is_some());
assert!(stk_name_taken(&rc_block("bash", "line", true)).is_none());
}
#[test]
fn blocking_policies_stop_an_unsigned_profile() {
for policy in ["Restricted", "restricted", "AllSigned", "allsigned"] {
assert!(policy_blocks_profile(policy), "{policy} should block");
}
}
#[test]
fn permissive_policies_run_a_local_profile() {
for policy in ["RemoteSigned", "Unrestricted", "Bypass"] {
assert!(!policy_blocks_profile(policy), "{policy} should not block");
}
}
}