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