Default sh cwd to workspace and drop ShOpts.cwd
Host mode always runs in the workspace; no concrete use case demands
a per-`(sh ...)` override yet. Dropping the field simplifies
`Cmd::run` to take a `&Path` and unconditionally chdir.

Assisted-by: Claude Opus 4.7 via Claude Code
change sqoxylstvourqzsrvlnulwpszpvpyspn
commit 56419ac6875041fe59a3e38651f1a2f58cdd172e
author Alpha Chen <alpha@kejadlen.dev>
date
parent nuumnvum
diff --git a/src/ci/mirror.rs b/src/ci/mirror.rs
index 3115bac..aed4418 100644
--- a/src/ci/mirror.rs
+++ b/src/ci/mirror.rs
@@ -66,7 +66,6 @@ impl MirrorJob {
 
         let git_opts = ShOpts {
             env: HashMap::from([("GIT_DIR".to_string(), git_dir)]),
-            cwd: None,
         };
 
         // Tag step.
@@ -92,11 +91,12 @@ impl MirrorJob {
         // Run via `Cmd::run` rather than `rt.sh` — we don't want the
         // encoded token landing in recorded outputs.
         let token_pair = format!("x-access-token:{secret}");
-        let encoded_output =
-            Cmd::Shell("printf '%s' \"$T\" | base64 --wrap=0".to_string()).run(ShOpts {
+        let encoded_output = Cmd::Shell("printf '%s' \"$T\" | base64 --wrap=0".to_string()).run(
+            ShOpts {
                 env: HashMap::from([("T".to_string(), token_pair)]),
-                cwd: None,
-            })?;
+            },
+            rt.workspace(),
+        )?;
         let auth_header = format!("Authorization: Basic {}", encoded_output.stdout.trim());
 
         // Push the configured refs (or the trigger ref, if none) plus the tag.
@@ -441,7 +441,13 @@ mod tests {
             RunFn::Rust(f) => f,
             RunFn::Lua(_) => panic!("mirror should register a RunFn::Rust"),
         };
-        let runtime = Rc::new(Runtime::new(pipeline, secrets, meta, git_dir));
+        let runtime = Rc::new(Runtime::new(
+            pipeline,
+            secrets,
+            meta,
+            git_dir,
+            std::env::current_dir().expect("cwd"),
+        ));
         let _ = RuntimeHandle(runtime.clone())
             .into_lua(runtime.lua())
             .expect("install runtime");
diff --git a/src/ci/run.rs b/src/ci/run.rs
index b10bbe4..6af0f8d 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -285,12 +285,18 @@ impl Run {
         workspace: &std::path::Path,
         executor: Executor,
     ) -> Result<HashMap<String, Vec<ShOutput>>> {
-        // `workspace` and `executor` are not yet read; later tasks
-        // wire them into the runtime and per-run container lifecycle.
-        let _ = (workspace, executor);
+        // `executor` is not yet read; later tasks wire it into the
+        // per-run container lifecycle.
+        let _ = executor;
         let meta = self.read_meta()?;
 
-        let runtime = Rc::new(Runtime::new(pipeline, secrets, &meta, git_dir));
+        let runtime = Rc::new(Runtime::new(
+            pipeline,
+            secrets,
+            &meta,
+            git_dir,
+            workspace.to_path_buf(),
+        ));
 
         let lua = runtime.lua();
         let rt_value = RuntimeHandle(runtime.clone())
@@ -958,6 +964,39 @@ mod tests {
         super::super::pipeline::compile(source, "ci.fnl").expect("compile should succeed")
     }
 
+    #[test]
+    fn host_mode_runs_sh_in_workspace() {
+        let (_dir, quire) = tmp_quire();
+        let runs = test_runs(&quire);
+        let run = runs.create(&test_meta()).expect("create");
+
+        let workspace = quire.base_dir().join("ws");
+        fs_err::create_dir_all(&workspace).expect("mkdir ws");
+        fs_err::write(workspace.join("marker"), "x").expect("write marker");
+
+        let pipeline = load(
+            r#"(local ci (require :quire.ci))
+(ci.job :pwd [:quire/push] (fn [{: sh}] (sh ["ls"])))"#,
+        );
+
+        let outputs = run
+            .execute(
+                pipeline,
+                HashMap::new(),
+                std::path::Path::new("."),
+                &workspace,
+                Executor::Host,
+            )
+            .expect("execute");
+        let pwd = &outputs["pwd"];
+        assert_eq!(pwd.len(), 1);
+        assert!(
+            pwd[0].stdout.contains("marker"),
+            "expected workspace ls to include marker, got: {:?}",
+            pwd[0].stdout,
+        );
+    }
+
     #[test]
     fn execute_records_outputs_per_job() {
         let (_dir, quire) = tmp_quire();
diff --git a/src/ci/runtime.rs b/src/ci/runtime.rs
index e049054..8763a99 100644
--- a/src/ci/runtime.rs
+++ b/src/ci/runtime.rs
@@ -43,6 +43,9 @@ pub(super) struct Runtime {
     pub(super) inputs: HashMap<String, HashMap<String, Option<mlua::Value>>>,
     pub(super) current_job: RefCell<Option<String>>,
     pub(super) outputs: RefCell<HashMap<String, Vec<ShOutput>>>,
+    /// The materialized workspace for this run. Every `(sh …)` call
+    /// runs here.
+    workspace: std::path::PathBuf,
 }
 
 impl Runtime {
@@ -62,6 +65,7 @@ impl Runtime {
         secrets: HashMap<String, SecretString>,
         meta: &RunMeta,
         git_dir: &std::path::Path,
+        workspace: std::path::PathBuf,
     ) -> Self {
         let transitive = pipeline.transitive_inputs();
         let lua = pipeline.fennel().lua();
@@ -100,6 +104,7 @@ impl Runtime {
             inputs,
             current_job: RefCell::new(None),
             outputs: RefCell::new(HashMap::new()),
+            workspace,
         }
     }
 
@@ -118,6 +123,11 @@ impl Runtime {
         self.pipeline.job(id)
     }
 
+    /// Borrow the run's materialized workspace path.
+    pub(super) fn workspace(&self) -> &std::path::Path {
+        &self.workspace
+    }
+
     /// Mark `id` as the currently executing job. `(sh …)` invocations
     /// from this job's `run_fn` will record output under `id`, and
     /// `(jobs …)` lookups will resolve against `id`'s view.
@@ -158,7 +168,7 @@ impl Runtime {
     /// current job (if one is active). Non-zero exits come back in
     /// `:exit`, not as `Err`.
     pub(super) fn sh(&self, cmd: Cmd, opts: ShOpts) -> crate::Result<ShOutput> {
-        let output = cmd.run(opts)?;
+        let output = cmd.run(opts, &self.workspace)?;
         if let Some(job) = self.current_job.borrow().as_ref() {
             self.outputs
                 .borrow_mut()
@@ -173,7 +183,8 @@ impl Runtime {
 #[cfg(test)]
 impl Runtime {
     /// Minimal constructor for tests — no source outputs, just
-    /// secrets and the pipeline's VM.
+    /// secrets and the pipeline's VM. Defaults the workspace to the
+    /// process CWD so tests that don't care about cwd keep working.
     fn for_test(pipeline: Pipeline, secrets: HashMap<String, SecretString>) -> Self {
         Self {
             pipeline,
@@ -181,6 +192,7 @@ impl Runtime {
             inputs: HashMap::new(),
             current_job: RefCell::new(None),
             outputs: RefCell::new(HashMap::new()),
+            workspace: std::env::current_dir().expect("cwd"),
         }
     }
 }
@@ -310,7 +322,8 @@ impl From<Cmd> for std::process::Command {
 impl Cmd {
     /// Spawn this command with the given options, blocking until exit,
     /// and capture the result. Inherits the runner's env with
-    /// `opts.env` merged on top.
+    /// `opts.env` merged on top. `cwd` becomes the child's working
+    /// directory unconditionally.
     //
     // TODO: stream stdout/stderr live instead of buffering. `output()`
     // captures the full child output in memory and only returns at exit,
@@ -321,15 +334,13 @@ impl Cmd {
     // Also revisit the `from_utf8_lossy` calls below — non-UTF-8 bytes
     // are silently replaced with U+FFFD and `:stdout` / `:stderr` end
     // up as mojibake with no signal that anything was lost.
-    pub(super) fn run(self, opts: ShOpts) -> std::io::Result<ShOutput> {
+    pub(super) fn run(self, opts: ShOpts, cwd: &std::path::Path) -> std::io::Result<ShOutput> {
         let cmd_str = format!("{self}");
         let mut command: std::process::Command = self.into();
         for (k, v) in opts.env {
             command.env(k, v);
         }
-        if let Some(cwd) = opts.cwd {
-            command.current_dir(cwd);
-        }
+        command.current_dir(cwd);
         let output = command.output()?;
         // Signal-killed processes have no exit code; collapse them to -1
         // for now. Surfacing the signal as a separate field is future work.
@@ -382,7 +393,6 @@ impl mlua::FromLua for Cmd {
 #[serde(default, deny_unknown_fields)]
 pub(super) struct ShOpts {
     pub(super) env: HashMap<String, String>,
-    pub(super) cwd: Option<String>,
 }
 
 impl mlua::FromLua for ShOpts {
@@ -524,21 +534,6 @@ mod tests {
         assert_eq!(r.stdout, "from-parent from-opts\n");
     }
 
-    #[test]
-    fn sh_honors_cwd() {
-        let dir = tempfile::tempdir().expect("tempdir");
-        // Resolve symlinks (macOS /tmp → /private/tmp) so the assertion holds.
-        let canonical = fs_err::canonicalize(dir.path()).expect("canonicalize");
-        let source = format!(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [{{: sh}}] (sh "pwd" {{:cwd "{}"}})))"#,
-            canonical.display()
-        );
-        let r = run_sh_via_job(&source);
-        assert_eq!(r.exit, 0);
-        assert_eq!(r.stdout.trim(), canonical.to_string_lossy());
-    }
-
     #[test]
     fn sh_rejects_unknown_opt_key() {
         let (runtime, run_fn) = rt(
@@ -553,7 +548,7 @@ mod tests {
         let msg = err.to_string();
         assert!(
             msg.contains("unknown field") && msg.contains("cwdir"),
-            "expected unknown-field error mentioning the typo, got: {msg}"
+            "expected unknown-field error mentioning the unknown key, got: {msg}"
         );
     }