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, Clone, 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
48fn write_last_to_git_dir(spec: &SpawnSpec, git_dir: &Path) -> Result<()> {
59 let mut persistent = spec.clone();
60 persistent.self_unlink = false;
63 let path = git_dir.join("gw-spawn-last.json");
64 let tmp = git_dir.join("gw-spawn-last.json.tmp");
65 let json = serde_json::to_vec(&persistent)?;
66 fs::write(&tmp, json)?;
67 fs::rename(&tmp, &path)?;
68 Ok(())
69}
70
71fn git_dir_for(cwd: &Path) -> Result<PathBuf> {
75 let output = std::process::Command::new("git")
76 .args(["rev-parse", "--git-dir"])
77 .current_dir(cwd)
78 .output()
79 .map_err(|e| CwError::Other(format!("spawn-ai: git rev-parse failed: {}", e)))?;
80 if !output.status.success() {
81 return Err(CwError::Other(format!(
82 "spawn-ai: not a git worktree: {}",
83 cwd.display()
84 )));
85 }
86 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
87 let p = PathBuf::from(&raw);
88 let absolute = if p.is_absolute() { p } else { cwd.join(p) };
89 Ok(absolute)
90}
91
92pub fn materialize(spec: &SpawnSpec) -> Result<(String, PathBuf)> {
95 materialize_in_dir(spec, &std::env::temp_dir())
96}
97
98pub fn materialize_in_dir(spec: &SpawnSpec, dir: &Path) -> Result<(String, PathBuf)> {
100 fs::create_dir_all(dir)?;
101
102 let named = tempfile::Builder::new()
104 .prefix("gw-spawn-")
105 .suffix(".json")
106 .rand_bytes(16)
107 .tempfile_in(dir)?;
108
109 let json = serde_json::to_vec(spec)?;
110 {
111 let mut f = named.as_file();
112 f.write_all(&json)?;
113 f.flush()?;
114 }
115
116 let (_file, path) = named.keep().map_err(|e| e.error)?;
119
120 if let Ok(git_dir) = git_dir_for(&spec.cwd) {
125 let _ = write_last_to_git_dir(spec, &git_dir);
126 }
127
128 let shell_line = format!("gw _spawn-ai {}", quote_path_for_shell(&path));
129 Ok((shell_line, path))
130}
131
132fn quote_path_for_shell(path: &Path) -> String {
138 let s = path.to_string_lossy();
139 let safe = s
144 .chars()
145 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':'));
146 if safe {
147 s.into_owned()
148 } else {
149 format!("\"{}\"", s)
150 }
151}
152
153pub fn read_spec(path: &Path) -> Result<SpawnSpec> {
157 let bytes = fs::read(path)
158 .map_err(|e| CwError::Other(format!("spawn-ai: read {} failed: {}", path.display(), e)))?;
159 let spec: SpawnSpec = serde_json::from_slice(&bytes)
160 .map_err(|e| CwError::Other(format!("spawn-ai: parse {} failed: {}", path.display(), e)))?;
161 if spec.version != SPEC_VERSION {
162 return Err(CwError::Other(format!(
163 "spawn-ai: unsupported spawn spec version: {} (expected {})",
164 spec.version, SPEC_VERSION
165 )));
166 }
167 if spec.argv.is_empty() {
168 return Err(CwError::Other("spawn-ai: spawn spec has empty argv".into()));
169 }
170 Ok(spec)
171}
172
173pub fn execute(spec_path: &Path) -> Result<()> {
183 let spec = read_spec(spec_path)?;
184
185 if spec.self_unlink {
186 let _ = fs::remove_file(spec_path);
188 }
189
190 std::env::set_current_dir(&spec.cwd).map_err(|e| {
191 CwError::Other(format!(
192 "spawn-ai: chdir to {} failed: {}",
193 spec.cwd.display(),
194 e
195 ))
196 })?;
197
198 let program = &spec.argv[0];
199 let args = &spec.argv[1..];
200
201 #[cfg(unix)]
202 {
203 use std::os::unix::process::CommandExt;
204 let err = std::process::Command::new(program).args(args).exec();
205 eprintln!("spawn-ai: exec {} failed: {}", program, err);
207 std::process::exit(127);
208 }
209
210 #[cfg(windows)]
211 {
212 let status = match std::process::Command::new(program).args(args).status() {
215 Ok(s) => s,
216 Err(e) => {
217 eprintln!("spawn-ai: spawn {} failed: {}", program, e);
218 std::process::exit(127);
219 }
220 };
221 let code = status.code().unwrap_or(1);
222 std::process::exit(code);
223 }
224}
225
226pub fn resolve_last_for_cwd() -> Result<PathBuf> {
230 let cwd = std::env::current_dir()
231 .map_err(|e| CwError::Other(format!("spawn-ai: cannot read current directory: {}", e)))?;
232 let git_dir = git_dir_for(&cwd)?;
233 let last = git_dir.join("gw-spawn-last.json");
234 if !last.exists() {
235 return Err(CwError::Other(format!(
236 "spawn-ai: no recent spawn found for this worktree (looked for {})",
237 last.display()
238 )));
239 }
240 Ok(last)
241}
242
243pub fn sweep_stale() {
247 sweep_stale_in(&std::env::temp_dir(), Duration::from_secs(24 * 3600));
248}
249
250fn sweep_stale_in(dir: &Path, max_age: Duration) {
251 let entries = match fs::read_dir(dir) {
252 Ok(it) => it,
253 Err(_) => return,
254 };
255 let now = SystemTime::now();
256 for entry in entries.flatten() {
257 let name = entry.file_name();
258 let name_str = name.to_string_lossy();
259 if !name_str.starts_with("gw-spawn-") || !name_str.ends_with(".json") {
260 continue;
261 }
262 let metadata = match fs::symlink_metadata(entry.path()) {
265 Ok(m) => m,
266 Err(_) => continue,
267 };
268 if !metadata.is_file() {
269 continue;
270 }
271 let mtime = match metadata.modified() {
272 Ok(t) => t,
273 Err(_) => continue,
274 };
275 if now.duration_since(mtime).unwrap_or_default() > max_age {
276 let _ = fs::remove_file(entry.path());
277 }
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 struct CwdGuard(std::path::PathBuf);
286 impl Drop for CwdGuard {
287 fn drop(&mut self) {
288 let _ = std::env::set_current_dir(&self.0);
289 }
290 }
291 impl CwdGuard {
292 fn enter(target: &std::path::Path) -> Self {
293 let prev = std::env::current_dir().unwrap();
294 std::env::set_current_dir(target).unwrap();
295 Self(prev)
296 }
297 }
298
299 #[test]
300 fn round_trip_preserves_killer_prompts() {
301 let killers = [
302 r#"Fix the bug where user can "escape" quotes"#,
303 r#"$(rm -rf /) — literal, not an expansion"#,
304 "한글 테스트 🚀 ${PATH}",
305 "multi\nline\n<<'EOF'\nnot a heredoc\nEOF\n",
306 r"C:\Users\foo\bar \\path\\with\\backslashes",
307 "`backtick` and 'single' and \"double\"",
308 ];
309 for prompt in killers {
310 let spec = SpawnSpec::new(
311 vec!["claude".into(), "--print".into(), prompt.into()],
312 PathBuf::from("/tmp/wt"),
313 );
314 let json = serde_json::to_string(&spec).unwrap();
315 let back: SpawnSpec = serde_json::from_str(&json).unwrap();
316 assert_eq!(spec, back, "round-trip mismatch for: {:?}", prompt);
317 assert_eq!(back.argv[2], prompt);
318 }
319 }
320
321 #[test]
322 fn large_prompt_round_trips() {
323 let big = "x".repeat(64 * 1024);
324 let spec = SpawnSpec::new(vec!["claude".into(), big.clone()], PathBuf::from("/tmp"));
325 let json = serde_json::to_string(&spec).unwrap();
326 let back: SpawnSpec = serde_json::from_str(&json).unwrap();
327 assert_eq!(back.argv[1], big);
328 }
329
330 #[test]
331 fn materialize_writes_spec_and_returns_shell_line() {
332 let dir = tempfile::tempdir().unwrap();
333 let spec = SpawnSpec::new(
334 vec!["/bin/echo".into(), "hello \"world\"".into()],
335 dir.path().to_path_buf(),
336 );
337 let (shell_line, spec_path) = materialize_in_dir(&spec, dir.path()).unwrap();
338
339 assert!(shell_line.starts_with("gw _spawn-ai "));
343 assert!(
348 !shell_line.starts_with("exec "),
349 "shell_line must not use exec: {:?}",
350 shell_line
351 );
352 assert!(spec_path.exists());
353
354 let loaded: SpawnSpec =
355 serde_json::from_str(&std::fs::read_to_string(&spec_path).unwrap()).unwrap();
356 assert_eq!(loaded, spec);
357 }
358
359 #[test]
360 fn materialize_filename_is_shell_safe() {
361 let dir = tempfile::tempdir().unwrap();
362 let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
363 let (line, _path) = materialize_in_dir(&spec, dir.path()).unwrap();
364
365 let tail = line.strip_prefix("gw _spawn-ai ").unwrap();
369 let quoted = tail.starts_with('"') && tail.ends_with('"');
370 let bare_safe = tail
371 .chars()
372 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':' | '\\'));
373 assert!(quoted || bare_safe, "unsafe tail: {:?}", tail);
374 }
375
376 #[cfg(unix)]
377 #[test]
378 fn materialize_file_is_mode_0600() {
379 use std::os::unix::fs::PermissionsExt;
380 let dir = tempfile::tempdir().unwrap();
381 let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
382 let (_line, path) = materialize_in_dir(&spec, dir.path()).unwrap();
383
384 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
385 assert_eq!(mode, 0o600, "expected 0600, got {:o}", mode);
386 }
387
388 #[test]
389 fn quote_path_for_shell_quotes_windows_backslashes() {
390 use std::path::PathBuf;
391 let win = PathBuf::from(r"C:\Users\me\AppData\Local\Temp\gw-spawn-abcdef0123456789.json");
392 let out = super::quote_path_for_shell(&win);
393 assert!(
395 out.starts_with('"') && out.ends_with('"'),
396 "expected quoted, got {:?}",
397 out
398 );
399 }
400
401 #[test]
402 fn quote_path_for_shell_bare_for_unix_paths() {
403 use std::path::PathBuf;
404 let unix = PathBuf::from("/tmp/gw-spawn-abcdef0123456789.json");
405 let out = super::quote_path_for_shell(&unix);
406 assert!(!out.starts_with('"'), "expected bare, got {:?}", out);
407 }
408
409 #[test]
410 fn read_spec_rejects_wrong_version() {
411 let dir = tempfile::tempdir().unwrap();
412 let path = dir.path().join("bad.json");
413 std::fs::write(
414 &path,
415 r#"{"version":999,"argv":["x"],"cwd":"/","self_unlink":false}"#,
416 )
417 .unwrap();
418 let err = read_spec(&path).unwrap_err();
419 assert!(format!("{err}").contains("unsupported spawn spec version"));
420 }
421
422 #[test]
423 fn read_spec_rejects_empty_argv() {
424 let dir = tempfile::tempdir().unwrap();
425 let path = dir.path().join("empty.json");
426 std::fs::write(
427 &path,
428 r#"{"version":1,"argv":[],"cwd":"/","self_unlink":false}"#,
429 )
430 .unwrap();
431 let err = read_spec(&path).unwrap_err();
432 assert!(format!("{err}").contains("empty argv"));
433 }
434
435 #[test]
436 fn read_spec_round_trip() {
437 let dir = tempfile::tempdir().unwrap();
438 let spec = SpawnSpec::new(
439 vec!["/bin/echo".into(), "hi".into()],
440 dir.path().to_path_buf(),
441 );
442 let path = dir.path().join("ok.json");
443 std::fs::write(&path, serde_json::to_vec(&spec).unwrap()).unwrap();
444 let loaded = read_spec(&path).unwrap();
445 assert_eq!(loaded, spec);
446 }
447
448 #[test]
449 fn materialize_writes_last_copy_into_git_dir() {
450 let dir = tempfile::tempdir().unwrap();
451 let worktree = dir.path();
452 let status = std::process::Command::new("git")
453 .args(["init", "-q"])
454 .current_dir(worktree)
455 .status()
456 .unwrap();
457 assert!(status.success());
458 let git_dir = worktree.join(".git");
459
460 let spec = SpawnSpec::new(
461 vec!["claude".into(), "--print".into(), "hello".into()],
462 worktree.to_path_buf(),
463 );
464
465 let temp = tempfile::tempdir().unwrap();
466 let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
467
468 let last = git_dir.join("gw-spawn-last.json");
469 assert!(
470 last.exists(),
471 "expected persistent copy at {}",
472 last.display()
473 );
474
475 let loaded: SpawnSpec =
476 serde_json::from_str(&std::fs::read_to_string(&last).unwrap()).unwrap();
477 assert!(
478 !loaded.self_unlink,
479 "persistent copy must have self_unlink=false"
480 );
481 assert_eq!(loaded.argv, spec.argv);
482 assert_eq!(loaded.cwd, spec.cwd);
483 }
484
485 #[test]
486 fn read_spec_does_not_observe_unlink_for_persistent_copy() {
487 let dir = tempfile::tempdir().unwrap();
492 let worktree = dir.path();
493 let status = std::process::Command::new("git")
494 .args(["init", "-q"])
495 .current_dir(worktree)
496 .status()
497 .unwrap();
498 assert!(status.success());
499
500 let spec = SpawnSpec::new(vec!["/bin/true".into()], worktree.to_path_buf());
501 let temp = tempfile::tempdir().unwrap();
502 let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
503
504 let last = worktree.join(".git").join("gw-spawn-last.json");
505 let loaded = read_spec(&last).expect("read_spec should succeed");
506 assert!(
507 !loaded.self_unlink,
508 "persistent copy must keep self_unlink=false"
509 );
510 }
511
512 #[test]
513 fn sweep_stale_removes_old_spec_files_only() {
514 use std::time::{Duration, SystemTime};
515 let dir = tempfile::tempdir().unwrap();
516
517 let old = dir.path().join("gw-spawn-old.json");
519 std::fs::write(&old, "{}").unwrap();
520 let past = SystemTime::now() - Duration::from_secs(48 * 3600);
521 filetime::set_file_mtime(&old, filetime::FileTime::from_system_time(past)).unwrap();
522
523 let recent = dir.path().join("gw-spawn-recent.json");
525 std::fs::write(&recent, "{}").unwrap();
526
527 let unrelated = dir.path().join("something-else.json");
529 std::fs::write(&unrelated, "{}").unwrap();
530 filetime::set_file_mtime(&unrelated, filetime::FileTime::from_system_time(past)).unwrap();
531
532 sweep_stale_in(dir.path(), Duration::from_secs(24 * 3600));
533
534 assert!(!old.exists(), "old gw-spawn file should be removed");
535 assert!(recent.exists(), "recent gw-spawn file should remain");
536 assert!(unrelated.exists(), "unrelated file should be untouched");
537 }
538
539 #[test]
540 #[serial_test::serial]
541 fn resolve_last_for_cwd_finds_persisted_spec() {
542 let dir = tempfile::tempdir().unwrap();
543 let worktree = dir.path();
544 let status = std::process::Command::new("git")
545 .args(["init", "-q"])
546 .current_dir(worktree)
547 .status()
548 .unwrap();
549 assert!(status.success());
550
551 let spec = SpawnSpec::new(
552 vec!["/bin/echo".into(), "hi".into()],
553 worktree.to_path_buf(),
554 );
555 let temp = tempfile::tempdir().unwrap();
556 let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
557
558 let _guard = CwdGuard::enter(worktree);
559 let resolved = resolve_last_for_cwd().expect("resolve_last_for_cwd should succeed");
560 assert!(
561 resolved.exists(),
562 "resolved path must exist: {}",
563 resolved.display()
564 );
565 assert!(
566 resolved.ends_with("gw-spawn-last.json"),
567 "unexpected resolved path: {}",
568 resolved.display()
569 );
570 }
571
572 #[test]
573 #[serial_test::serial]
574 fn resolve_last_for_cwd_errors_when_no_spec_persisted() {
575 let dir = tempfile::tempdir().unwrap();
576 let worktree = dir.path();
577 let status = std::process::Command::new("git")
578 .args(["init", "-q"])
579 .current_dir(worktree)
580 .status()
581 .unwrap();
582 assert!(status.success());
583
584 let _guard = CwdGuard::enter(worktree);
585 let result = resolve_last_for_cwd();
586
587 let err = result.unwrap_err();
588 let msg = format!("{err}");
589 assert!(
590 msg.contains("no recent spawn") || msg.contains("gw-spawn-last.json"),
591 "unexpected error: {}",
592 msg
593 );
594 assert!(msg.starts_with("spawn-ai:"), "missing prefix: {}", msg);
595 }
596
597 #[test]
598 #[serial_test::serial]
599 fn resolve_last_for_cwd_errors_outside_a_git_worktree() {
600 let dir = tempfile::tempdir().unwrap();
601
602 let _guard = CwdGuard::enter(dir.path());
603 let result = resolve_last_for_cwd();
604
605 let err = result.unwrap_err();
606 let msg = format!("{err}");
607 assert!(msg.starts_with("spawn-ai:"), "missing prefix: {}", msg);
608 }
609}