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