git_worktree_manager/operations/
spawn_spec.rs1use std::fs;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, SystemTime};
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::{CwError, Result};
17
18pub const SPEC_VERSION: u32 = 1;
19
20#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
21pub struct SpawnSpec {
22 pub version: u32,
23 pub argv: Vec<String>,
24 pub cwd: PathBuf,
25 pub self_unlink: bool,
26}
27
28impl SpawnSpec {
29 pub fn new(argv: Vec<String>, cwd: PathBuf) -> Self {
30 Self {
31 version: SPEC_VERSION,
32 argv,
33 cwd,
34 self_unlink: true,
35 }
36 }
37}
38
39pub fn materialize(spec: &SpawnSpec) -> Result<(String, PathBuf)> {
42 materialize_in_dir(spec, &std::env::temp_dir())
43}
44
45pub fn materialize_in_dir(spec: &SpawnSpec, dir: &Path) -> Result<(String, PathBuf)> {
47 fs::create_dir_all(dir)?;
48
49 let named = tempfile::Builder::new()
51 .prefix("gw-spawn-")
52 .suffix(".json")
53 .rand_bytes(16)
54 .tempfile_in(dir)?;
55
56 let json = serde_json::to_vec(spec)?;
57 {
58 let mut f = named.as_file();
59 f.write_all(&json)?;
60 f.flush()?;
61 }
62
63 let (_file, path) = named.keep().map_err(|e| e.error)?;
66
67 let shell_line = format!("exec gw _spawn-ai {}", quote_path_for_shell(&path));
68 Ok((shell_line, path))
69}
70
71fn quote_path_for_shell(path: &Path) -> String {
77 let s = path.to_string_lossy();
78 let safe = s
83 .chars()
84 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':'));
85 if safe {
86 s.into_owned()
87 } else {
88 format!("\"{}\"", s)
89 }
90}
91
92pub fn read_spec(path: &Path) -> Result<SpawnSpec> {
96 let bytes = fs::read(path)
97 .map_err(|e| CwError::Other(format!("spawn-ai: read {} failed: {}", path.display(), e)))?;
98 let spec: SpawnSpec = serde_json::from_slice(&bytes)
99 .map_err(|e| CwError::Other(format!("spawn-ai: parse {} failed: {}", path.display(), e)))?;
100 if spec.version != SPEC_VERSION {
101 return Err(CwError::Other(format!(
102 "spawn-ai: unsupported spawn spec version: {} (expected {})",
103 spec.version, SPEC_VERSION
104 )));
105 }
106 if spec.argv.is_empty() {
107 return Err(CwError::Other("spawn-ai: spawn spec has empty argv".into()));
108 }
109 Ok(spec)
110}
111
112pub fn execute(spec_path: &Path) -> Result<()> {
122 let spec = read_spec(spec_path)?;
123
124 if spec.self_unlink {
125 let _ = fs::remove_file(spec_path);
127 }
128
129 std::env::set_current_dir(&spec.cwd).map_err(|e| {
130 CwError::Other(format!(
131 "spawn-ai: chdir to {} failed: {}",
132 spec.cwd.display(),
133 e
134 ))
135 })?;
136
137 let program = &spec.argv[0];
138 let args = &spec.argv[1..];
139
140 #[cfg(unix)]
141 {
142 use std::os::unix::process::CommandExt;
143 let err = std::process::Command::new(program).args(args).exec();
144 eprintln!("spawn-ai: exec {} failed: {}", program, err);
146 std::process::exit(127);
147 }
148
149 #[cfg(windows)]
150 {
151 let status = match std::process::Command::new(program).args(args).status() {
154 Ok(s) => s,
155 Err(e) => {
156 eprintln!("spawn-ai: spawn {} failed: {}", program, e);
157 std::process::exit(127);
158 }
159 };
160 let code = status.code().unwrap_or(1);
161 std::process::exit(code);
162 }
163}
164
165pub fn sweep_stale() {
169 sweep_stale_in(&std::env::temp_dir(), Duration::from_secs(24 * 3600));
170}
171
172fn sweep_stale_in(dir: &Path, max_age: Duration) {
173 let entries = match fs::read_dir(dir) {
174 Ok(it) => it,
175 Err(_) => return,
176 };
177 let now = SystemTime::now();
178 for entry in entries.flatten() {
179 let name = entry.file_name();
180 let name_str = name.to_string_lossy();
181 if !name_str.starts_with("gw-spawn-") || !name_str.ends_with(".json") {
182 continue;
183 }
184 let metadata = match fs::symlink_metadata(entry.path()) {
187 Ok(m) => m,
188 Err(_) => continue,
189 };
190 if !metadata.is_file() {
191 continue;
192 }
193 let mtime = match metadata.modified() {
194 Ok(t) => t,
195 Err(_) => continue,
196 };
197 if now.duration_since(mtime).unwrap_or_default() > max_age {
198 let _ = fs::remove_file(entry.path());
199 }
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn round_trip_preserves_killer_prompts() {
209 let killers = [
210 r#"Fix the bug where user can "escape" quotes"#,
211 r#"$(rm -rf /) — literal, not an expansion"#,
212 "한글 테스트 🚀 ${PATH}",
213 "multi\nline\n<<'EOF'\nnot a heredoc\nEOF\n",
214 r"C:\Users\foo\bar \\path\\with\\backslashes",
215 "`backtick` and 'single' and \"double\"",
216 ];
217 for prompt in killers {
218 let spec = SpawnSpec::new(
219 vec!["claude".into(), "--print".into(), prompt.into()],
220 PathBuf::from("/tmp/wt"),
221 );
222 let json = serde_json::to_string(&spec).unwrap();
223 let back: SpawnSpec = serde_json::from_str(&json).unwrap();
224 assert_eq!(spec, back, "round-trip mismatch for: {:?}", prompt);
225 assert_eq!(back.argv[2], prompt);
226 }
227 }
228
229 #[test]
230 fn large_prompt_round_trips() {
231 let big = "x".repeat(64 * 1024);
232 let spec = SpawnSpec::new(vec!["claude".into(), big.clone()], PathBuf::from("/tmp"));
233 let json = serde_json::to_string(&spec).unwrap();
234 let back: SpawnSpec = serde_json::from_str(&json).unwrap();
235 assert_eq!(back.argv[1], big);
236 }
237
238 #[test]
239 fn materialize_writes_spec_and_returns_exec_line() {
240 let dir = tempfile::tempdir().unwrap();
241 let spec = SpawnSpec::new(
242 vec!["/bin/echo".into(), "hello \"world\"".into()],
243 dir.path().to_path_buf(),
244 );
245 let (shell_line, spec_path) = materialize_in_dir(&spec, dir.path()).unwrap();
246
247 assert!(shell_line.starts_with("exec gw _spawn-ai "));
248 assert!(spec_path.exists());
249
250 let loaded: SpawnSpec =
251 serde_json::from_str(&std::fs::read_to_string(&spec_path).unwrap()).unwrap();
252 assert_eq!(loaded, spec);
253 }
254
255 #[test]
256 fn materialize_filename_is_shell_safe() {
257 let dir = tempfile::tempdir().unwrap();
258 let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
259 let (line, _path) = materialize_in_dir(&spec, dir.path()).unwrap();
260
261 let tail = line.strip_prefix("exec gw _spawn-ai ").unwrap();
265 let quoted = tail.starts_with('"') && tail.ends_with('"');
266 let bare_safe = tail
267 .chars()
268 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':' | '\\'));
269 assert!(quoted || bare_safe, "unsafe tail: {:?}", tail);
270 }
271
272 #[cfg(unix)]
273 #[test]
274 fn materialize_file_is_mode_0600() {
275 use std::os::unix::fs::PermissionsExt;
276 let dir = tempfile::tempdir().unwrap();
277 let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
278 let (_line, path) = materialize_in_dir(&spec, dir.path()).unwrap();
279
280 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
281 assert_eq!(mode, 0o600, "expected 0600, got {:o}", mode);
282 }
283
284 #[test]
285 fn quote_path_for_shell_quotes_windows_backslashes() {
286 use std::path::PathBuf;
287 let win = PathBuf::from(r"C:\Users\me\AppData\Local\Temp\gw-spawn-abcdef0123456789.json");
288 let out = super::quote_path_for_shell(&win);
289 assert!(
291 out.starts_with('"') && out.ends_with('"'),
292 "expected quoted, got {:?}",
293 out
294 );
295 }
296
297 #[test]
298 fn quote_path_for_shell_bare_for_unix_paths() {
299 use std::path::PathBuf;
300 let unix = PathBuf::from("/tmp/gw-spawn-abcdef0123456789.json");
301 let out = super::quote_path_for_shell(&unix);
302 assert!(!out.starts_with('"'), "expected bare, got {:?}", out);
303 }
304
305 #[test]
306 fn read_spec_rejects_wrong_version() {
307 let dir = tempfile::tempdir().unwrap();
308 let path = dir.path().join("bad.json");
309 std::fs::write(
310 &path,
311 r#"{"version":999,"argv":["x"],"cwd":"/","self_unlink":false}"#,
312 )
313 .unwrap();
314 let err = read_spec(&path).unwrap_err();
315 assert!(format!("{err}").contains("unsupported spawn spec version"));
316 }
317
318 #[test]
319 fn read_spec_rejects_empty_argv() {
320 let dir = tempfile::tempdir().unwrap();
321 let path = dir.path().join("empty.json");
322 std::fs::write(
323 &path,
324 r#"{"version":1,"argv":[],"cwd":"/","self_unlink":false}"#,
325 )
326 .unwrap();
327 let err = read_spec(&path).unwrap_err();
328 assert!(format!("{err}").contains("empty argv"));
329 }
330
331 #[test]
332 fn read_spec_round_trip() {
333 let dir = tempfile::tempdir().unwrap();
334 let spec = SpawnSpec::new(
335 vec!["/bin/echo".into(), "hi".into()],
336 dir.path().to_path_buf(),
337 );
338 let path = dir.path().join("ok.json");
339 std::fs::write(&path, serde_json::to_vec(&spec).unwrap()).unwrap();
340 let loaded = read_spec(&path).unwrap();
341 assert_eq!(loaded, spec);
342 }
343
344 #[test]
345 fn sweep_stale_removes_old_spec_files_only() {
346 use std::time::{Duration, SystemTime};
347 let dir = tempfile::tempdir().unwrap();
348
349 let old = dir.path().join("gw-spawn-old.json");
351 std::fs::write(&old, "{}").unwrap();
352 let past = SystemTime::now() - Duration::from_secs(48 * 3600);
353 filetime::set_file_mtime(&old, filetime::FileTime::from_system_time(past)).unwrap();
354
355 let recent = dir.path().join("gw-spawn-recent.json");
357 std::fs::write(&recent, "{}").unwrap();
358
359 let unrelated = dir.path().join("something-else.json");
361 std::fs::write(&unrelated, "{}").unwrap();
362 filetime::set_file_mtime(&unrelated, filetime::FileTime::from_system_time(past)).unwrap();
363
364 sweep_stale_in(dir.path(), Duration::from_secs(24 * 3600));
365
366 assert!(!old.exists(), "old gw-spawn file should be removed");
367 assert!(recent.exists(), "recent gw-spawn file should remain");
368 assert!(unrelated.exists(), "unrelated file should be untouched");
369 }
370}