1use anyhow::{bail, Context, Result};
2use std::path::Path;
3use std::process::{Command, Stdio};
4
5fn try_terminal_with_init(path: &Path, command: &str, init_script: &str) -> Result<bool> {
9 let path_str = path
10 .to_str()
11 .context("Workspace path contains non-UTF-8 characters")?;
12
13 let path_escaped = path_str.replace('\'', "'\\''");
15
16 let bootstrap = format!(
17 "#!/bin/sh\ncd '{}'\n{}\nexec \"${{SHELL:-sh}}\"\n",
18 path_escaped, init_script
19 );
20
21 let tmp_path = std::env::temp_dir()
22 .join(format!("worktree-hook-open-{}.sh", std::process::id()));
23 std::fs::write(&tmp_path, bootstrap.as_bytes())?;
24
25 #[cfg(unix)]
26 {
27 use std::os::unix::fs::PermissionsExt;
28 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
29 }
30
31 let tmp_str = tmp_path
32 .to_str()
33 .context("Temp path contains non-UTF-8 characters")?;
34 let cmd_lower = command.to_ascii_lowercase();
35
36 if cmd_lower.contains("iterm") {
37 let script = format!(
38 r#"tell application "iTerm2" to create window with default profile command "sh {}""#,
39 tmp_str
40 );
41 Command::new("osascript")
42 .args(["-e", &script])
43 .stdin(Stdio::null())
44 .stdout(Stdio::null())
45 .stderr(Stdio::null())
46 .spawn()?;
47 Ok(true)
48 } else if cmd_lower.contains("open -a terminal") {
49 Command::new("open")
50 .args(["-a", "Terminal", tmp_str])
51 .stdin(Stdio::null())
52 .stdout(Stdio::null())
53 .stderr(Stdio::null())
54 .spawn()?;
55 Ok(true)
56 } else if cmd_lower.starts_with("alacritty") {
57 Command::new("alacritty")
58 .args(["--working-directory", path_str, "-e", "sh", tmp_str])
59 .stdin(Stdio::null())
60 .stdout(Stdio::null())
61 .stderr(Stdio::null())
62 .spawn()?;
63 Ok(true)
64 } else if cmd_lower.starts_with("kitty") {
65 Command::new("kitty")
66 .args(["--directory", path_str, "sh", tmp_str])
67 .stdin(Stdio::null())
68 .stdout(Stdio::null())
69 .stderr(Stdio::null())
70 .spawn()?;
71 Ok(true)
72 } else if cmd_lower.starts_with("wezterm") {
73 Command::new("wezterm")
74 .args(["start", "--cwd", path_str, "--", "sh", tmp_str])
75 .stdin(Stdio::null())
76 .stdout(Stdio::null())
77 .stderr(Stdio::null())
78 .spawn()?;
79 Ok(true)
80 } else {
81 Ok(false)
82 }
83}
84
85fn app_exists(name: &str) -> bool {
87 std::path::Path::new(&format!("/Applications/{name}.app")).exists()
88 || std::path::Path::new(&format!("/System/Applications/{name}.app")).exists()
89}
90
91fn open_hook_in_auto_terminal(path: &Path, init_script: &str) -> Result<bool> {
95 let candidates: &[(&str, &str)] = &[
96 ("iTerm", "open -a iTerm ."),
97 ("Warp", "open -a Warp ."),
98 ("Ghostty", "open -a Ghostty ."),
99 ("Terminal", "open -a Terminal ."),
100 ];
101 for &(app, cmd) in candidates {
102 if app_exists(app) && try_terminal_with_init(path, cmd, init_script)? {
103 return Ok(true);
104 }
105 }
106 Ok(false)
107}
108
109pub fn open_with_hook(path: &Path, command: &str, init_script: &str) -> Result<bool> {
114 if try_terminal_with_init(path, command, init_script)? {
115 return Ok(true);
116 }
117 open_in_editor(path, command)?;
119 open_hook_in_auto_terminal(path, init_script)
120}
121
122pub fn open_in_editor(path: &Path, command: &str) -> Result<()> {
126 let path_str = path
127 .to_str()
128 .context("Workspace path contains non-UTF-8 characters")?;
129
130 let cmd_str = if command.contains(" . ") || command.ends_with(" .") || command == "." {
132 command.replacen(" .", &format!(" {path_str}"), 1)
133 } else {
134 format!("{command} {path_str}")
135 };
136
137 run_shell_command(&cmd_str)
138 .with_context(|| format!("Failed to open editor with command: {cmd_str}"))
139}
140
141fn run_shell_command(cmd: &str) -> Result<()> {
143 let mut parts = shlex_split(cmd);
144 if parts.is_empty() {
145 bail!("Empty command");
146 }
147 let program = parts.remove(0);
148 Command::new(&program)
149 .args(&parts)
150 .env("PATH", augmented_path())
151 .stdin(Stdio::null())
152 .stdout(Stdio::null())
153 .stderr(Stdio::null())
154 .spawn()
155 .with_context(|| format!("Failed to spawn {program}"))?;
156 Ok(())
157}
158
159pub fn augmented_path() -> String {
162 let current = std::env::var("PATH").unwrap_or_default();
163 let extras = [
164 "/usr/local/bin",
165 "/opt/homebrew/bin",
166 "/opt/homebrew/sbin",
167 ];
168 let mut parts: Vec<&str> = extras.iter().copied().collect();
169 for p in current.split(':').filter(|s| !s.is_empty()) {
170 if !parts.contains(&p) {
171 parts.push(p);
172 }
173 }
174 parts.join(":")
175}
176
177fn shlex_split(s: &str) -> Vec<String> {
179 let mut parts = Vec::new();
180 let mut current = String::new();
181 let mut in_quotes = false;
182 let mut chars = s.chars().peekable();
183
184 while let Some(c) = chars.next() {
185 match c {
186 '"' => in_quotes = !in_quotes,
187 ' ' | '\t' if !in_quotes => {
188 if !current.is_empty() {
189 parts.push(current.clone());
190 current.clear();
191 }
192 }
193 _ => current.push(c),
194 }
195 }
196 if !current.is_empty() {
197 parts.push(current);
198 }
199 parts
200}