Skip to main content

git_worktree_manager/operations/
spawn_spec.rs

1//! Spawn-spec — safely launch AI tools without shell escape hazards.
2//!
3//! Prompts with quotes/$/backticks/newlines break when re-quoted through
4//! AppleScript/wezterm/tmux send-text layers. Instead, `materialize` writes
5//! argv+cwd to a temp file and returns `<self-exe> _spawn-ai <spec-path>`
6//! as the launcher command, where `<self-exe>` is the absolute path of the
7//! currently-running binary (so multi-binary installs call back into the
8//! same binary instead of resolving `gw` via PATH). `execute` reads the
9//! spec, unlinks it, chdir's, and execvp's the real tool — the pane shell
10//! only ever parses ASCII (or quoted ASCII for paths with spaces).
11//!
12//! The emitted line intentionally does NOT use `exec` so that when it is
13//! fed into an already-running interactive shell (e.g. `wezterm cli
14//! send-text`, iTerm AppleScript `write text`, `tmux send-keys` into a
15//! session pane), the shell survives the AI tool's exit and keeps the
16//! tab/pane open at its prompt. Launchers that run the line as the pane's
17//! sole process via `bash -lc <line>` (tmux-window, tmux-pane-*, zellij-*)
18//! are unaffected either way: their pane still closes when the AI tool
19//! exits because the `bash -lc` invocation has nothing else to do.
20
21use std::fs;
22use std::io::Write;
23use std::path::{Path, PathBuf};
24use std::time::{Duration, SystemTime};
25
26use serde::{Deserialize, Serialize};
27
28use crate::error::{CwError, Result};
29
30pub const SPEC_VERSION: u32 = 1;
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct SpawnSpec {
34    pub version: u32,
35    pub argv: Vec<String>,
36    pub cwd: PathBuf,
37    pub self_unlink: bool,
38}
39
40impl SpawnSpec {
41    pub fn new(argv: Vec<String>, cwd: PathBuf) -> Self {
42        Self {
43            version: SPEC_VERSION,
44            argv,
45            cwd,
46            self_unlink: true,
47        }
48    }
49}
50
51/// Write a non-self-unlinking copy of `spec` to `<git-dir>/gw-spawn-last.json`
52/// so the user can recover from a corrupted launcher line by running
53/// `gw _spawn-ai` (no args) inside the worktree.
54///
55/// `git_dir` is the path returned by `git rev-parse --git-dir` — for the
56/// main checkout this is `<repo>/.git/`, for linked worktrees it is
57/// `<repo>/.git/worktrees/<name>/`. Either way the file lives per-worktree.
58///
59/// Best-effort: any IO error is returned; callers may choose to swallow it
60/// because the launcher itself still works without the persistent copy.
61fn write_last_to_git_dir(spec: &SpawnSpec, git_dir: &Path) -> Result<()> {
62    let mut persistent = spec.clone();
63    // Critical: persistent copy must survive `execute()`'s unlink so the
64    // user can re-run `gw _spawn-ai` repeatedly after a corruption event.
65    persistent.self_unlink = false;
66    let path = git_dir.join("gw-spawn-last.json");
67    let tmp = git_dir.join("gw-spawn-last.json.tmp");
68    let json = serde_json::to_vec(&persistent)?;
69    fs::write(&tmp, json)?;
70    fs::rename(&tmp, &path)?;
71    Ok(())
72}
73
74/// Look up the git-dir for `cwd` via `git rev-parse --git-dir`. Returns the
75/// absolute path to the `.git` directory (or `.git/worktrees/<name>/` for
76/// linked worktrees).
77fn git_dir_for(cwd: &Path) -> Result<PathBuf> {
78    let output = std::process::Command::new("git")
79        .args(["rev-parse", "--git-dir"])
80        .current_dir(cwd)
81        .output()
82        .map_err(|e| CwError::Other(format!("spawn-ai: git rev-parse failed: {}", e)))?;
83    if !output.status.success() {
84        return Err(CwError::Other(format!(
85            "spawn-ai: not a git worktree: {}",
86            cwd.display()
87        )));
88    }
89    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
90    let p = PathBuf::from(&raw);
91    let absolute = if p.is_absolute() { p } else { cwd.join(p) };
92    Ok(absolute)
93}
94
95/// Write `spec` to a 0600 tempfile in the system temp dir and return
96/// `(shell_line, spec_path)`. `shell_line` is safe to hand to any launcher.
97pub fn materialize(spec: &SpawnSpec) -> Result<(String, PathBuf)> {
98    materialize_in_dir(spec, &std::env::temp_dir())
99}
100
101/// Test seam — write into an explicit directory.
102pub fn materialize_in_dir(spec: &SpawnSpec, dir: &Path) -> Result<(String, PathBuf)> {
103    fs::create_dir_all(dir)?;
104
105    // tempfile gives us a random name + O_CREAT|O_EXCL + mode 0600 on Unix.
106    let named = tempfile::Builder::new()
107        .prefix("gw-spawn-")
108        .suffix(".json")
109        .rand_bytes(16)
110        .tempfile_in(dir)?;
111
112    let json = serde_json::to_vec(spec)?;
113    {
114        let mut f = named.as_file();
115        f.write_all(&json)?;
116        f.flush()?;
117    }
118
119    // Persist — stop tempfile from auto-deleting on drop. `_spawn-ai` unlinks
120    // it after reading, and the 24h sweep handles crash residue.
121    let (_file, path) = named.keep().map_err(|e| e.error)?;
122
123    // Best-effort persistent copy for `gw _spawn-ai` (no args) recovery.
124    // If `spec.cwd` is not a git worktree (unusual — AI tools always run
125    // inside one) the failure is swallowed: the launcher still works via
126    // the random temp path.
127    if let Ok(git_dir) = git_dir_for(&spec.cwd) {
128        let _ = write_last_to_git_dir(spec, &git_dir);
129    }
130
131    // Use the absolute path of the currently-running binary so that, in
132    // multi-install setups (e.g. `gw` from Homebrew + `gw-new` from cargo),
133    // the new tab/pane invokes the SAME binary that just emitted this line —
134    // not whichever `gw` happens to be first on PATH (which may be a different
135    // version, an alias, or missing entirely).
136    //
137    // Test override: `CW_SPAWN_AI_BIN` lets integration tests point at the
138    // cargo-built `gw` binary instead of the test runner's own `current_exe`
139    // (which would be `target/debug/deps/test_X-…` — a binary that has no
140    // `_spawn-ai` subcommand).
141    //
142    // No `"gw"` string fallback: silently falling back to the bare name would
143    // re-introduce the exact PATH-resolution ambiguity this fix exists to
144    // remove. If `current_exe()` is unavailable (chroot, missing /proc on
145    // Linux, very rare), we'd rather surface the error.
146    let self_exe = if let Some(s) = std::env::var_os("CW_SPAWN_AI_BIN") {
147        quote_path_for_shell(Path::new(&s))
148    } else {
149        let exe = std::env::current_exe().map_err(|e| {
150            CwError::Other(format!(
151                "spawn-ai: cannot resolve current executable path: {}. \
152                 Set CW_SPAWN_AI_BIN to override.",
153                e
154            ))
155        })?;
156        quote_path_for_shell(&exe)
157    };
158    let shell_line = format!("{} _spawn-ai {}", self_exe, quote_path_for_shell(&path));
159    Ok((shell_line, path))
160}
161
162/// Shell-safe rendering for a path we just created. Paths produced by
163/// `tempfile_in(temp_dir())` normally contain only [A-Za-z0-9_/.-], but some
164/// Windows `%TEMP%` expansions include spaces; in that case we wrap in double
165/// quotes. Our own filename never contains `"`, `$`, or backslash-escaped
166/// metacharacters, so double quotes are sufficient under both bash and cmd.
167fn quote_path_for_shell(path: &Path) -> String {
168    let s = path.to_string_lossy();
169    // Backslash is NOT bare-safe: bash/zsh/tmux/wezterm interpret `\X` as an
170    // escape, which would corrupt Windows paths like C:\Users\...\Temp\...
171    // Any path containing `\` (or other unsafe chars) takes the quoted branch,
172    // which is fine under both bash and cmd because our filename is ASCII.
173    let safe = s
174        .chars()
175        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':'));
176    if safe {
177        s.into_owned()
178    } else {
179        format!("\"{}\"", s)
180    }
181}
182
183/// Parse a spec file, rejecting unsupported versions and empty argv.
184/// All errors are prefixed with `spawn-ai:` so the entrypoint can print
185/// them verbatim without duplicating the prefix.
186pub fn read_spec(path: &Path) -> Result<SpawnSpec> {
187    let bytes = fs::read(path)
188        .map_err(|e| CwError::Other(format!("spawn-ai: read {} failed: {}", path.display(), e)))?;
189    let spec: SpawnSpec = serde_json::from_slice(&bytes)
190        .map_err(|e| CwError::Other(format!("spawn-ai: parse {} failed: {}", path.display(), e)))?;
191    if spec.version != SPEC_VERSION {
192        return Err(CwError::Other(format!(
193            "spawn-ai: unsupported spawn spec version: {} (expected {})",
194            spec.version, SPEC_VERSION
195        )));
196    }
197    if spec.argv.is_empty() {
198        return Err(CwError::Other("spawn-ai: spawn spec has empty argv".into()));
199    }
200    Ok(spec)
201}
202
203/// Execute a spawn spec. Never returns to the caller:
204/// - Unix: `execvp` replaces the current process. On exec failure we print a
205///   diagnostic to stderr and `exit(127)` ("command not found" convention).
206/// - Windows: spawns a child, waits, and exits with the child's code. Spawn
207///   failures also exit 127.
208///
209/// The `Result<()>` return type exists only so the caller can surface
210/// pre-spawn errors (spec read, parse, chdir) through the normal error path;
211/// once `execvp`/spawn is attempted, the process exits directly.
212pub fn execute(spec_path: &Path) -> Result<()> {
213    let spec = read_spec(spec_path)?;
214
215    if spec.self_unlink {
216        // Best-effort — proceed even if unlink fails (e.g. already gone).
217        let _ = fs::remove_file(spec_path);
218    }
219
220    std::env::set_current_dir(&spec.cwd).map_err(|e| {
221        CwError::Other(format!(
222            "spawn-ai: chdir to {} failed: {}",
223            spec.cwd.display(),
224            e
225        ))
226    })?;
227
228    let program = &spec.argv[0];
229    let args = &spec.argv[1..];
230
231    #[cfg(unix)]
232    {
233        use std::os::unix::process::CommandExt;
234        let err = std::process::Command::new(program).args(args).exec();
235        // exec only returns on failure.
236        eprintln!("spawn-ai: exec {} failed: {}", program, err);
237        std::process::exit(127);
238    }
239
240    #[cfg(windows)]
241    {
242        // Exit directly with the child's code to mirror Unix execvp semantics
243        // as closely as we can on Windows (no true process replacement).
244        let status = match std::process::Command::new(program).args(args).status() {
245            Ok(s) => s,
246            Err(e) => {
247                eprintln!("spawn-ai: spawn {} failed: {}", program, e);
248                std::process::exit(127);
249            }
250        };
251        let code = status.code().unwrap_or(1);
252        std::process::exit(code);
253    }
254}
255
256/// Resolve the last persisted spec path for the current working directory's
257/// worktree. Reads `<git-dir>/gw-spawn-last.json`. Errors are prefixed with
258/// `spawn-ai:` so the entrypoint can print them verbatim.
259pub fn resolve_last_for_cwd() -> Result<PathBuf> {
260    let cwd = std::env::current_dir()
261        .map_err(|e| CwError::Other(format!("spawn-ai: cannot read current directory: {}", e)))?;
262    let git_dir = git_dir_for(&cwd)?;
263    let last = git_dir.join("gw-spawn-last.json");
264    if !last.exists() {
265        return Err(CwError::Other(format!(
266            "spawn-ai: no recent spawn found for this worktree (looked for {})",
267            last.display()
268        )));
269    }
270    Ok(last)
271}
272
273/// Best-effort removal of stale `gw-spawn-*.json` temp files from the system
274/// temp directory. Intended to run once at `gw` startup. All errors are
275/// swallowed — this is a safety net, not a correctness mechanism.
276pub fn sweep_stale() {
277    sweep_stale_in(&std::env::temp_dir(), Duration::from_secs(24 * 3600));
278}
279
280fn sweep_stale_in(dir: &Path, max_age: Duration) {
281    let entries = match fs::read_dir(dir) {
282        Ok(it) => it,
283        Err(_) => return,
284    };
285    let now = SystemTime::now();
286    for entry in entries.flatten() {
287        let name = entry.file_name();
288        let name_str = name.to_string_lossy();
289        if !name_str.starts_with("gw-spawn-") || !name_str.ends_with(".json") {
290            continue;
291        }
292        // symlink_metadata + is_file narrows the TOCTOU window: we refuse to
293        // follow symlinks or delete directories that happen to match the name.
294        let metadata = match fs::symlink_metadata(entry.path()) {
295            Ok(m) => m,
296            Err(_) => continue,
297        };
298        if !metadata.is_file() {
299            continue;
300        }
301        let mtime = match metadata.modified() {
302            Ok(t) => t,
303            Err(_) => continue,
304        };
305        if now.duration_since(mtime).unwrap_or_default() > max_age {
306            let _ = fs::remove_file(entry.path());
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    struct CwdGuard(std::path::PathBuf);
316    impl Drop for CwdGuard {
317        fn drop(&mut self) {
318            let _ = std::env::set_current_dir(&self.0);
319        }
320    }
321    impl CwdGuard {
322        fn enter(target: &std::path::Path) -> Self {
323            let prev = std::env::current_dir().unwrap();
324            std::env::set_current_dir(target).unwrap();
325            Self(prev)
326        }
327    }
328
329    #[test]
330    fn round_trip_preserves_killer_prompts() {
331        let killers = [
332            r#"Fix the bug where user can "escape" quotes"#,
333            r#"$(rm -rf /) — literal, not an expansion"#,
334            "한글 테스트 🚀 ${PATH}",
335            "multi\nline\n<<'EOF'\nnot a heredoc\nEOF\n",
336            r"C:\Users\foo\bar \\path\\with\\backslashes",
337            "`backtick` and 'single' and \"double\"",
338        ];
339        for prompt in killers {
340            let spec = SpawnSpec::new(
341                vec!["claude".into(), "--print".into(), prompt.into()],
342                PathBuf::from("/tmp/wt"),
343            );
344            let json = serde_json::to_string(&spec).unwrap();
345            let back: SpawnSpec = serde_json::from_str(&json).unwrap();
346            assert_eq!(spec, back, "round-trip mismatch for: {:?}", prompt);
347            assert_eq!(back.argv[2], prompt);
348        }
349    }
350
351    #[test]
352    fn large_prompt_round_trips() {
353        let big = "x".repeat(64 * 1024);
354        let spec = SpawnSpec::new(vec!["claude".into(), big.clone()], PathBuf::from("/tmp"));
355        let json = serde_json::to_string(&spec).unwrap();
356        let back: SpawnSpec = serde_json::from_str(&json).unwrap();
357        assert_eq!(back.argv[1], big);
358    }
359
360    #[test]
361    fn materialize_writes_spec_and_returns_shell_line() {
362        let dir = tempfile::tempdir().unwrap();
363        let spec = SpawnSpec::new(
364            vec!["/bin/echo".into(), "hello \"world\"".into()],
365            dir.path().to_path_buf(),
366        );
367        let (shell_line, spec_path) = materialize_in_dir(&spec, dir.path()).unwrap();
368
369        // The launcher line begins with the absolute path of the test binary
370        // (current_exe). We check the `_spawn-ai` subcommand rather than a
371        // fixed `gw` prefix so the line works under any binary name (gw,
372        // gw-new, etc.) — see the comment in materialize_in_dir.
373        let exe = std::env::current_exe().unwrap();
374        let exe_token = quote_path_for_shell(&exe);
375        assert!(
376            shell_line.starts_with(&format!("{} _spawn-ai ", exe_token)),
377            "expected line to start with {:?} _spawn-ai, got {:?}",
378            exe_token,
379            shell_line
380        );
381        // No `exec` — the shell must survive after the AI tool exits so the
382        // terminal tab/pane stays open (e.g. WezTerm tab keeps the zsh prompt
383        // after claude quits).
384        assert!(
385            !shell_line.starts_with("exec "),
386            "shell_line must not use exec: {:?}",
387            shell_line
388        );
389        assert!(spec_path.exists());
390
391        let loaded: SpawnSpec =
392            serde_json::from_str(&std::fs::read_to_string(&spec_path).unwrap()).unwrap();
393        assert_eq!(loaded, spec);
394    }
395
396    #[test]
397    fn materialize_filename_is_shell_safe() {
398        let dir = tempfile::tempdir().unwrap();
399        let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
400        let (line, _path) = materialize_in_dir(&spec, dir.path()).unwrap();
401
402        // "<self-exe> _spawn-ai " + path. The path tail must contain only
403        // safe chars OR be wrapped in double quotes. Temp dir in tests may
404        // have unsafe chars; we only assert the emitted tail is one of those
405        // two shapes.
406        let exe = std::env::current_exe().unwrap();
407        let exe_token = quote_path_for_shell(&exe);
408        let prefix = format!("{} _spawn-ai ", exe_token);
409        let tail = line
410            .strip_prefix(&prefix)
411            .unwrap_or_else(|| panic!("line does not start with {:?}: {:?}", prefix, line));
412        let quoted = tail.starts_with('"') && tail.ends_with('"');
413        let bare_safe = tail
414            .chars()
415            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':' | '\\'));
416        assert!(quoted || bare_safe, "unsafe tail: {:?}", tail);
417    }
418
419    #[cfg(unix)]
420    #[test]
421    fn materialize_file_is_mode_0600() {
422        use std::os::unix::fs::PermissionsExt;
423        let dir = tempfile::tempdir().unwrap();
424        let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
425        let (_line, path) = materialize_in_dir(&spec, dir.path()).unwrap();
426
427        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
428        assert_eq!(mode, 0o600, "expected 0600, got {:o}", mode);
429    }
430
431    #[test]
432    fn quote_path_for_shell_quotes_windows_backslashes() {
433        use std::path::PathBuf;
434        let win = PathBuf::from(r"C:\Users\me\AppData\Local\Temp\gw-spawn-abcdef0123456789.json");
435        let out = super::quote_path_for_shell(&win);
436        // Must be quoted — bare would let bash interpret the backslashes.
437        assert!(
438            out.starts_with('"') && out.ends_with('"'),
439            "expected quoted, got {:?}",
440            out
441        );
442    }
443
444    #[test]
445    fn quote_path_for_shell_bare_for_unix_paths() {
446        use std::path::PathBuf;
447        let unix = PathBuf::from("/tmp/gw-spawn-abcdef0123456789.json");
448        let out = super::quote_path_for_shell(&unix);
449        assert!(!out.starts_with('"'), "expected bare, got {:?}", out);
450    }
451
452    #[test]
453    fn read_spec_rejects_wrong_version() {
454        let dir = tempfile::tempdir().unwrap();
455        let path = dir.path().join("bad.json");
456        std::fs::write(
457            &path,
458            r#"{"version":999,"argv":["x"],"cwd":"/","self_unlink":false}"#,
459        )
460        .unwrap();
461        let err = read_spec(&path).unwrap_err();
462        assert!(format!("{err}").contains("unsupported spawn spec version"));
463    }
464
465    #[test]
466    fn read_spec_rejects_empty_argv() {
467        let dir = tempfile::tempdir().unwrap();
468        let path = dir.path().join("empty.json");
469        std::fs::write(
470            &path,
471            r#"{"version":1,"argv":[],"cwd":"/","self_unlink":false}"#,
472        )
473        .unwrap();
474        let err = read_spec(&path).unwrap_err();
475        assert!(format!("{err}").contains("empty argv"));
476    }
477
478    #[test]
479    fn read_spec_round_trip() {
480        let dir = tempfile::tempdir().unwrap();
481        let spec = SpawnSpec::new(
482            vec!["/bin/echo".into(), "hi".into()],
483            dir.path().to_path_buf(),
484        );
485        let path = dir.path().join("ok.json");
486        std::fs::write(&path, serde_json::to_vec(&spec).unwrap()).unwrap();
487        let loaded = read_spec(&path).unwrap();
488        assert_eq!(loaded, spec);
489    }
490
491    #[test]
492    fn materialize_writes_last_copy_into_git_dir() {
493        let dir = tempfile::tempdir().unwrap();
494        let worktree = dir.path();
495        let status = std::process::Command::new("git")
496            .args(["init", "-q"])
497            .current_dir(worktree)
498            .status()
499            .unwrap();
500        assert!(status.success());
501        let git_dir = worktree.join(".git");
502
503        let spec = SpawnSpec::new(
504            vec!["claude".into(), "--print".into(), "hello".into()],
505            worktree.to_path_buf(),
506        );
507
508        let temp = tempfile::tempdir().unwrap();
509        let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
510
511        let last = git_dir.join("gw-spawn-last.json");
512        assert!(
513            last.exists(),
514            "expected persistent copy at {}",
515            last.display()
516        );
517
518        let loaded: SpawnSpec =
519            serde_json::from_str(&std::fs::read_to_string(&last).unwrap()).unwrap();
520        assert!(
521            !loaded.self_unlink,
522            "persistent copy must have self_unlink=false"
523        );
524        assert_eq!(loaded.argv, spec.argv);
525        assert_eq!(loaded.cwd, spec.cwd);
526    }
527
528    #[test]
529    fn read_spec_does_not_observe_unlink_for_persistent_copy() {
530        // Sanity check: the persistent copy is written with self_unlink=false,
531        // and `execute()`'s unlink branch only fires when self_unlink=true.
532        // Locks the on-disk default in via the public `read_spec` API so
533        // a future refactor that flips the default is caught here.
534        let dir = tempfile::tempdir().unwrap();
535        let worktree = dir.path();
536        let status = std::process::Command::new("git")
537            .args(["init", "-q"])
538            .current_dir(worktree)
539            .status()
540            .unwrap();
541        assert!(status.success());
542
543        let spec = SpawnSpec::new(vec!["/bin/true".into()], worktree.to_path_buf());
544        let temp = tempfile::tempdir().unwrap();
545        let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
546
547        let last = worktree.join(".git").join("gw-spawn-last.json");
548        let loaded = read_spec(&last).expect("read_spec should succeed");
549        assert!(
550            !loaded.self_unlink,
551            "persistent copy must keep self_unlink=false"
552        );
553    }
554
555    #[test]
556    fn sweep_stale_removes_old_spec_files_only() {
557        use std::time::{Duration, SystemTime};
558        let dir = tempfile::tempdir().unwrap();
559
560        // Old spec file — mtime far in the past.
561        let old = dir.path().join("gw-spawn-old.json");
562        std::fs::write(&old, "{}").unwrap();
563        let past = SystemTime::now() - Duration::from_secs(48 * 3600);
564        filetime::set_file_mtime(&old, filetime::FileTime::from_system_time(past)).unwrap();
565
566        // Recent spec file — should survive.
567        let recent = dir.path().join("gw-spawn-recent.json");
568        std::fs::write(&recent, "{}").unwrap();
569
570        // Unrelated file — should survive regardless of age.
571        let unrelated = dir.path().join("something-else.json");
572        std::fs::write(&unrelated, "{}").unwrap();
573        filetime::set_file_mtime(&unrelated, filetime::FileTime::from_system_time(past)).unwrap();
574
575        sweep_stale_in(dir.path(), Duration::from_secs(24 * 3600));
576
577        assert!(!old.exists(), "old gw-spawn file should be removed");
578        assert!(recent.exists(), "recent gw-spawn file should remain");
579        assert!(unrelated.exists(), "unrelated file should be untouched");
580    }
581
582    #[test]
583    #[serial_test::serial]
584    fn resolve_last_for_cwd_finds_persisted_spec() {
585        let dir = tempfile::tempdir().unwrap();
586        let worktree = dir.path();
587        let status = std::process::Command::new("git")
588            .args(["init", "-q"])
589            .current_dir(worktree)
590            .status()
591            .unwrap();
592        assert!(status.success());
593
594        let spec = SpawnSpec::new(
595            vec!["/bin/echo".into(), "hi".into()],
596            worktree.to_path_buf(),
597        );
598        let temp = tempfile::tempdir().unwrap();
599        let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
600
601        let _guard = CwdGuard::enter(worktree);
602        let resolved = resolve_last_for_cwd().expect("resolve_last_for_cwd should succeed");
603        assert!(
604            resolved.exists(),
605            "resolved path must exist: {}",
606            resolved.display()
607        );
608        assert!(
609            resolved.ends_with("gw-spawn-last.json"),
610            "unexpected resolved path: {}",
611            resolved.display()
612        );
613    }
614
615    #[test]
616    #[serial_test::serial]
617    fn resolve_last_for_cwd_errors_when_no_spec_persisted() {
618        let dir = tempfile::tempdir().unwrap();
619        let worktree = dir.path();
620        let status = std::process::Command::new("git")
621            .args(["init", "-q"])
622            .current_dir(worktree)
623            .status()
624            .unwrap();
625        assert!(status.success());
626
627        let _guard = CwdGuard::enter(worktree);
628        let result = resolve_last_for_cwd();
629
630        let err = result.unwrap_err();
631        let msg = format!("{err}");
632        assert!(
633            msg.contains("no recent spawn") || msg.contains("gw-spawn-last.json"),
634            "unexpected error: {}",
635            msg
636        );
637        assert!(msg.starts_with("spawn-ai:"), "missing prefix: {}", msg);
638    }
639
640    #[test]
641    #[serial_test::serial]
642    fn resolve_last_for_cwd_errors_outside_a_git_worktree() {
643        let dir = tempfile::tempdir().unwrap();
644
645        let _guard = CwdGuard::enter(dir.path());
646        let result = resolve_last_for_cwd();
647
648        let err = result.unwrap_err();
649        let msg = format!("{err}");
650        assert!(msg.starts_with("spawn-ai:"), "missing prefix: {}", msg);
651    }
652}